add shortcuts traner

This commit is contained in:
2026-10-28 09:36:55 +08:00
parent 0b2072a05f
commit 784a8e3e4b
12 changed files with 1423 additions and 0 deletions

View File

@@ -0,0 +1,85 @@
# 快捷键训练比赛系统
这是一个独立的小程序。学生账号直接使用原分组系统的 `selfgroup.db`;训练相关数据表也创建在同一个数据库里,但都带 `trainer_` 前缀,不会改动原来的分组表。
## 启动
在本仓库根目录执行:
```bash
python3 shortcut_trainer/app.py
```
- 学生端:<http://127.0.0.1:8010/>
- 教师端:<http://127.0.0.1:8010/teacher>
程序默认用 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` 等系统级快捷键;普通网页通常无法可靠拦截这些按键,也无法检测学生手工输入网址或真实窗口菜单。这些内容适合演示和人工观察。训练系统用自定义组合键和模拟窗口完成可计分练习。

795
shortcut_trainer/app.py Normal file
View File

@@ -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<? OR (finished_at=? AND student_id<=?)
)""",
(round_id, row["finished_at"], row["finished_at"], student_id),
).fetchone()["n"]
return {"rank": int(rank or 1), "total": int(total or 1), "finishedAt": int(row["finished_at"])}
def teacher_state(self) -> 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()

Binary file not shown.

Binary file not shown.

View File

@@ -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=`<strong>操作成功</strong><span>完成时间:${formatClock(current.finishedAt)}</span><span class="rank">当前排名:第 ${Number.isFinite(rank)&&rank>0?rank:1} 名 / 共 ${Number.isFinite(total)&&total>0?total:1} 人</span>`}
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}

View File

@@ -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=`<span class="kb">Alt + ${p.key}</span><h3>${p.page}. ${p.title}</h3><p>${p.desc}</p>`;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);

View File

@@ -0,0 +1,13 @@
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>快捷键训练场</title><link rel="stylesheet" href="/style.css"></head><body>
<header class="topbar"><div class="brand"><i class="brand-mark">键</i><span>Windows 快捷键训练比赛</span></div><div class="toolbar hidden" id="toolbar"><span class="chip" id="userChip"></span><button class="ghost" id="logout">退出</button></div></header>
<main class="page">
<section id="loginArea"><form class="login-card" id="loginForm" style="max-width:440px;margin:8vh auto"><h1 class="title">学生登录</h1><p class="subtitle">使用分组系统的学号和密码登录</p><label>学号</label><input id="username" autocomplete="username" required><label>密码</label><input id="password" type="password" autocomplete="current-password" required><button class="full success">进入训练场</button><div class="notice" id="notice"></div></form><form id="passwordChange" class="hidden"><label>原密码</label><input id="oldPassword" type="password"><label>新密码</label><input id="newPassword" type="password"><button class="full">修改密码</button></form></section>
<section id="mainArea" class="hidden"><div class="command"><small>教师指令 / 当前任务</small><h2 id="commandText">等待老师开始比赛…</h2><div class="chip" id="roundState">未开始</div><div id="taskResult" class="task-result hidden"></div></div>
<h2>打开训练页 <span class="chip">4–6 个标签</span></h2><p class="hint">点击按钮,或按 <span class="kbd">Alt</span>+<span class="kbd">P / Q / R / T / Y / U</span>。若 Chrome 拦截弹窗,请允许“始终允许弹出式窗口”。</p>
<div class="pages-grid" id="pages"></div>
<section class="panel"><h2>快捷键帮助</h2><div class="help">
<div><span class="kbd">Ctrl</span>+<span class="kbd">T</span><br>新建标签页</div><div><span class="kbd">Ctrl</span>+<span class="kbd">3</span><br>切到第3个标签</div><div><span class="kbd">Ctrl</span>+<span class="kbd">W</span><br>关闭当前标签</div><div><span class="kbd">Ctrl</span>+<span class="kbd">Tab</span><br>下一个标签</div>
<div><span class="kbd">Alt</span>+<span class="kbd">←</span><br>后退</div><div><span class="kbd">Alt</span>+<span class="kbd">→</span><br>前进</div><div><span class="kbd">F5</span><br>刷新页面</div><div><span class="kbd">Ctrl</span>+<span class="kbd">F</span><br>页内查找</div>
<div><span class="kbd">Ctrl</span>+<span class="kbd">D</span><br>收藏</div><div><span class="kbd">Ctrl</span>+<span class="kbd">J</span><br>下载列表</div><div><span class="kbd">Alt</span>+<span class="kbd">F4</span><br>关闭当前窗口</div><div><span class="kbd">Win</span>+<span class="kbd">D</span><br>显示桌面</div></div>
<p class="hint">说明:Ctrl+T/W/N、Alt+F4、Win+D 这类系统级快捷键常被 Windows/浏览器优先接管,网页不能可靠检测,只能用于讲解和人工观察。</p></section></section>
</main><script src="/common.js"></script><script src="/home.js"></script></body></html>

View File

@@ -0,0 +1,7 @@
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>训练页</title><link rel="stylesheet" href="/style.css"></head><body data-trainer-page="1"><header class="topbar"><div class="brand"><i class="brand-mark" id="mark">1</i><span id="pageTitle">训练页</span></div><div class="toolbar"><span class="chip" id="who">加载中</span><a href="/" target="_blank"><button class="ghost">控制台</button></a></div></header>
<main class="page"><div class="command"><small>当前指令</small><h2 id="commandText">等待老师开始…</h2><div id="taskResult" class="task-result hidden"></div></div>
<section class="panel"><h2 id="pageHeading">页面信息</h2><p id="pageDesc" class="subtitle">正在准备本页内容…</p><div id="pageFacts" class="help"></div></section>
<div class="grid page-main"><section class="panel"><h2>功能面板</h2><p class="hint">类似 Word 功能区:先按 <span class="kbd">Alt</span>+<span class="kbd">K</span> / <span class="kbd">H</span> / <span class="kbd">B</span> 展开对应面板,再按面板里的字母。</p><div id="ribbon" class="ribbon"></div></section>
<section class="panel"><h2>本页练习</h2><p class="hint">外部链接:按 <span class="kbd">Alt</span>+<span class="kbd">B</span> 展开保障面板,再按 <span class="kbd">L</span>,然后按 <span class="kbd">D</span> 打开百度,或按 <span class="kbd">F</span> 打开 Bilibili。不必继续按着 Alt。</p><p class="hint">在页面空白处右键,可打开练习菜单;菜单打开后按 <span class="kbd">E</span>/<span class="kbd">R</span>/<span class="kbd">C</span>。</p></section></div></main>
<div id="contextMenu" class="panel hidden" style="position:fixed;z-index:99;width:190px;padding:6px"><button class="full ghost" data-menu="refresh">刷新 <span class="kbd">E</span></button><button class="full ghost" data-menu="rename">重命名 <span class="kbd">R</span></button><button class="full ghost" data-menu="copy">复制 <span class="kbd">C</span></button></div>
<script src="/common.js"></script><script src="/page.js"></script></body></html>

View File

@@ -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=`<span class="keytip">Alt+${panel.key}</span><strong>${panel.title}</strong>`;
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=`<span class="keytip">${item.key}</span><span>${item.name}</span>`;
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=`<span class="keytip">${item.key}</span><span>${item.index}. ${item.name}</span>`;
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})});

View File

@@ -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}}

View File

@@ -0,0 +1,34 @@
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>教师比赛控制台</title><link rel="stylesheet" href="/style.css"></head><body><header class="topbar"><div class="brand"><i class="brand-mark">师</i><span>快捷键比赛控制台</span></div><div class="toolbar hidden" id="toolbar"><span class="chip" id="roundState">未开始</span><button class="ghost" onclick="location.reload()">刷新</button><button class="ghost" id="logout">退出</button></div></header>
<main class="page"><section id="loginArea"><form class="login-card" id="loginForm" style="max-width:420px;margin:10vh auto"><h1 class="title">教师登录</h1><label>账号</label><input id="username" value="teacher"><label>密码</label><input id="password" type="password"><button class="full success">登录</button><div class="notice" id="notice"></div></form></section>
<section id="mainArea" class="hidden teacher-grid">
<div><section class="panel"><h2>发布比赛指令</h2><div class="builder"><div class="builder-grid"><div><label>目标页面</label><select id="builderPage"></select></div><div><label>学生要完成的动作</label><select id="builderAction"></select></div><div id="choiceField"><label id="choiceLabel">具体目标</label><select id="builderChoice"></select></div></div><div id="builderTip" class="builder-tip"></div><label>学生端显示文案(可修改)</label><textarea id="label" rows="3"></textarea><div class="expected-preview"><small>本轮自动判定</small><div id="expectedPreview"></div></div><details class="advanced"><summary>查看自动生成的判定 JSON</summary><textarea id="expected" rows="8" readonly></textarea></details><div style="display:flex;gap:10px;margin-top:12px"><button class="success" id="start">立即发布本轮</button><button class="danger" id="stop">结束指令</button></div></div><div class="preset-panel"><h3>常用模板</h3><div id="presets"></div></div><div id="message" class="notice"></div></section>
<section class="panel"><h2>操作流水</h2><div id="events" class="list"></div></section></div>
<div><section class="panel"><h2>前10名</h2><div id="scores" class="list"></div></section><section class="panel"><h2>学生在线</h2><div id="students" class="list"></div></section></div>
</div>
<section class="panel accounts-panel">
<h2>学生账号</h2>
<p class="hint">测试账号 <span class="kbd">test01</span> 到 <span class="kbd">test05</span>,初始密码 <span class="kbd">test1234</span>,班级为「测试」。与分组系统共用同一学生库。</p>
<div class="builder-grid">
<div><label>筛选班级</label><select id="accountClass"><option value="">全部班级</option></select></div>
<div><label>搜索学号 / 姓名</label><input id="studentNoSearch" type="search" placeholder="输入学号或姓名"></div>
<div><label>学生</label><select id="studentSelect"></select></div>
</div>
<form id="studentPasswordForm" class="builder-grid" style="margin-top:12px">
<div><label>新密码</label><input id="studentNewPassword" type="password" autocomplete="new-password" minlength="8" placeholder="至少 8 位" required></div>
<div><label>确认新密码</label><input id="studentConfirmPassword" type="password" autocomplete="new-password" minlength="8" placeholder="再次输入新密码" required></div>
<div class="accounts-actions"><button class="success" type="submit">修改密码</button></div>
</form>
<div class="notice" id="studentPasswordNotice"></div>
<p class="hint">修改后请把新密码告知学生;该学生的所有已登录会话会立即退出。</p>
<h3>添加学生</h3>
<form id="studentCreateForm" class="builder-grid">
<div><label>班级</label><input id="newClassName" placeholder="例如 测试" required></div>
<div><label>学号</label><input id="newStudentNo" required></div>
<div><label>姓名</label><input id="newStudentName" required></div>
<div><label>初始密码</label><input id="newStudentPassword" type="password" minlength="8" placeholder="至少 8 位" required></div>
<div class="accounts-actions"><button type="submit">添加账号</button></div>
</form>
<div class="notice" id="studentCreateNotice"></div>
</section>
</section>
</main><script src="/common.js"></script><script src="/teacher.js"></script></body></html>

View File

@@ -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 `<option value="${value}"${selected?' selected':''}>${text}</option>`}
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=`<button class="ghost">${item.name}</button><button class="ghost">填入</button><button class="success">发布</button>`;
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=`<span class="dot"></span><span>${st.name} · ${st.student_no} · ${st.class_name}</span><span class="chip">${st.online?'在线':'离线'}</span>`;
element('students').appendChild(row);
});
element('scores').innerHTML='';
if(!s.scores.length)element('scores').innerHTML='<div class="hint">暂无完成记录</div>';
s.scores.forEach(x=>{
const row=document.createElement('div');
row.className='row winner';
row.innerHTML=`<span class="score">#${x.rank}</span><span>${x.name} · ${x.student_no} · ${x.class_name}</span><span>${new Date(x.finished_at*1000).toLocaleTimeString()}</span>`;
element('scores').appendChild(row);
});
element('events').innerHTML='';
if(!s.events.length)element('events').innerHTML='<div class="hint">暂无操作</div>';
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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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='<option value="">全部班级</option>'+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(''):'<option value="">未找到匹配的学生</option>';
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();