add shortcuts traner
This commit is contained in:
85
shortcut_trainer/README.md
Normal file
85
shortcut_trainer/README.md
Normal 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
795
shortcut_trainer/app.py
Normal 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()
|
||||||
BIN
shortcut_trainer/shortcut_trainer.db-shm
Normal file
BIN
shortcut_trainer/shortcut_trainer.db-shm
Normal file
Binary file not shown.
BIN
shortcut_trainer/shortcut_trainer.db-wal
Normal file
BIN
shortcut_trainer/shortcut_trainer.db-wal
Normal file
Binary file not shown.
28
shortcut_trainer/static/common.js
Normal file
28
shortcut_trainer/static/common.js
Normal 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}
|
||||||
9
shortcut_trainer/static/home.js
Normal file
9
shortcut_trainer/static/home.js
Normal 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);
|
||||||
13
shortcut_trainer/static/index.html
Normal file
13
shortcut_trainer/static/index.html
Normal 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>
|
||||||
7
shortcut_trainer/static/page.html
Normal file
7
shortcut_trainer/static/page.html
Normal 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>
|
||||||
140
shortcut_trainer/static/page.js
Normal file
140
shortcut_trainer/static/page.js
Normal 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})});
|
||||||
61
shortcut_trainer/static/style.css
Normal file
61
shortcut_trainer/static/style.css
Normal 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}}
|
||||||
34
shortcut_trainer/static/teacher.html
Normal file
34
shortcut_trainer/static/teacher.html
Normal 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>
|
||||||
251
shortcut_trainer/static/teacher.js
Normal file
251
shortcut_trainer/static/teacher.js
Normal 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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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();
|
||||||
Reference in New Issue
Block a user