From c6e9a82fe5fedff90620947c600827b51d107659 Mon Sep 17 00:00:00 2001 From: Shuming Liu Date: Tue, 8 Sep 2026 10:06:45 +0800 Subject: [PATCH] first commit --- .gitignore | 6 + 2025mt.xlsx | Bin 0 -> 27311 bytes README.md | 90 ++++ app.py | 1136 +++++++++++++++++++++++++++++++++++++++++++ start.sh | 4 + static/index.html | 91 ++++ static/shared.js | 53 ++ static/student.js | 211 ++++++++ static/style.css | 156 ++++++ static/teacher.html | 75 +++ static/teacher.js | 225 +++++++++ xlsxio.py | 242 +++++++++ 12 files changed, 2289 insertions(+) create mode 100644 .gitignore create mode 100644 2025mt.xlsx create mode 100644 README.md create mode 100644 app.py create mode 100755 start.sh create mode 100644 static/index.html create mode 100644 static/shared.js create mode 100644 static/student.js create mode 100644 static/style.css create mode 100644 static/teacher.html create mode 100644 static/teacher.js create mode 100644 xlsxio.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a6c4911 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +__pycache__/ +*.pyc +selfgroup.db +*.db +.upload-roster.xlsx +.DS_Store diff --git a/2025mt.xlsx b/2025mt.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..2f1a1ba85ae4e68e0537d8938749cb301761e78b GIT binary patch literal 27311 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 Zf77%m$-(}5SuyW8kl^p+qRsAc{|ARGN*VwF literal 0 HcmV?d00001 diff --git a/README.md b/README.md new file mode 100644 index 0000000..5167958 --- /dev/null +++ b/README.md @@ -0,0 +1,90 @@ +# 学生自助分组系统 + +这是一个基于 B/S 架构的本地/校园网分组系统。项目使用 Python 标准库实现,不需要安装第三方依赖;数据库为 SQLite,学生名单来源为 `2025mt.xlsx`。 + +## 功能 + +- 学生用学号登录,首次登录密码也是学号,并强制修改密码。 +- 学生登录后显示姓名、学号和登录状态。 +- 学生可创建组并自动成为组长;其他学生可选择加入。 +- 每组最多 6 人,包括组长。 +- 已加入其他组的学生不能创建新组;组长加入新组时,需确认解散原组;普通成员切换组时需确认退出原组。 +- 学生端和教师端都能直接看到全部组及成员的学号、姓名,无需点开详情。 +- 教师可切换“当前开放登录班级”,未开放班级不能登录。 +- 教师可停止/恢复学生分组操作。 +- 停止后学生只能查看;教师仍可手工建组、改名、设组长、加入、移出、解散。 +- 教师可导出当前班分组为 `.xlsx`。 + +## 启动 + +在本目录执行: + +```bash +python3 app.py +``` + +默认访问地址: + +- 学生端: +- 教师端: + +如果学生要从其他电脑/手机访问,请把 `127.0.0.1` 换成老师电脑的局域网 IP,例如: + +```text +http://192.168.1.20:8000/ +``` + +可自定义端口和数据库: + +```bash +PORT=8000 GROUP_DB=selfgroup.db python3 app.py +``` + +## 教师账号 + +默认账号: + +```text +账号:teacher +密码:teacher123 +``` + +正式使用前建议改用环境变量设置: + +```bash +TEACHER_USER=yourname TEACHER_PASSWORD=your-strong-password python3 app.py +``` + +注意:教师密码只在首次创建数据库时写入。如果数据库已存在,修改环境变量后还需要删除旧数据库或直接更新数据库中 `settings` 表的 `teacher_password`。 + +## 学生名单 + +服务第一次启动时,如果没有数据库,会自动读取当前目录的 `2025mt.xlsx`。表格至少需要以下表头: + +```text +班级 学号 姓名 +``` + +也兼容读取:性别、录取编号、备注。 + +如果换了名单,可以在教师后台上传新的 `.xlsx`。导入会重建学生账号并解散全部已有分组。 + +## 数据文件 + +- `selfgroup.db`:学生、密码、分组、班级状态和登录会话。 +- `2025mt.xlsx`:首次启动导入的原始名单。 + +## 建议部署方式 + +1. 在教师电脑上运行 `python3 app.py`。 +2. 查看教师电脑局域网 IP。 +3. 将学生端网址发到班级群。 +4. 教师打开 `/teacher`,切换到要分组的班级。 +5. 分组完成后点击“停止学生操作”。 +6. 检查并手工调整分组,然后导出 Excel。 + +## 注意 + +- 学生密码使用 PBKDF2 加盐哈希保存,不以明文保存。 +- 系统适合局域网使用;如果在公网部署,请使用 HTTPS 反向代理,并务必修改教师默认密码。 +- SQLite 适合班级规模的小型并发使用,不建议多名教师同时频繁写数据库。 diff --git a/app.py b/app.py new file mode 100644 index 0000000..d422e00 --- /dev/null +++ b/app.py @@ -0,0 +1,1136 @@ +#!/usr/bin/env python3 +"""学生自助分组系统 - B/S 架构,仅使用 Python 标准库。""" + +from __future__ import annotations + +import hashlib +import http.server +import hmac +import json +import logging +import os +import secrets +import sqlite3 +import threading +import time +import urllib.parse +from http import HTTPStatus +from pathlib import Path +from typing import Any + +from xlsxio import export_filename, read_xlsx_rows, write_xlsx + + +BASE_DIR = Path(__file__).resolve().parent +DB_PATH = Path(os.environ.get("GROUP_DB", BASE_DIR / "selfgroup.db")) +XLSX_PATH = Path(os.environ.get("GROUP_XLSX", BASE_DIR / "2025mt.xlsx")) +STATIC_DIR = BASE_DIR / "static" +TEACHER_USER = os.environ.get("TEACHER_USER", "teacher") +TEACHER_PASSWORD = os.environ.get("TEACHER_PASSWORD", "teacher123") +GROUP_LIMIT = 6 +SESSION_DAYS = 7 + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") +LOG = logging.getLogger("group-system") + + +def hash_password(password: str, salt: bytes | None = None) -> str: + salt = salt or secrets.token_bytes(16) + digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), 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 = bytes.fromhex(salt_hex) + expected = bytes.fromhex(digest_hex) + except ValueError: + return False + actual = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, 180_000) + return hmac.compare_digest(actual, expected) + + +class GroupStore: + def __init__(self, path: Path): + self.path = path + self.lock = threading.RLock() + self.connection = sqlite3.connect(str(path), check_same_thread=False) + self.connection.row_factory = sqlite3.Row + self.connection.execute("PRAGMA foreign_keys = ON") + self.init_database() + + def init_database(self) -> None: + with self.lock, self.connection: + self.connection.executescript( + """ + CREATE TABLE IF NOT EXISTS students ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + class_name TEXT NOT NULL, + student_no TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + gender TEXT DEFAULT '', + admission_no TEXT DEFAULT '', + remark TEXT DEFAULT '', + password_hash TEXT NOT NULL, + must_change_password INTEGER NOT NULL DEFAULT 1 + ); + CREATE TABLE IF NOT EXISTS groups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + class_name TEXT NOT NULL, + name TEXT NOT NULL, + leader_student_id INTEGER REFERENCES students(id) ON DELETE SET NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + CREATE TABLE IF NOT EXISTS group_members ( + group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE, + student_id INTEGER NOT NULL UNIQUE REFERENCES students(id) ON DELETE CASCADE, + joined_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + CREATE TABLE IF NOT EXISTS class_controls ( + class_name TEXT PRIMARY KEY, + locked INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS sessions ( + token_hash TEXT PRIMARY KEY, + role TEXT NOT NULL, + username TEXT NOT NULL, + student_id INTEGER REFERENCES students(id) ON DELETE CASCADE, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL + ); + """ + ) + self.connection.execute( + "INSERT OR IGNORE INTO settings(key,value) VALUES('active_class','1')" + ) + self.connection.execute( + "INSERT OR IGNORE INTO settings(key,value) VALUES('teacher_username',?)", + (TEACHER_USER,), + ) + self.connection.execute( + "INSERT OR IGNORE INTO settings(key,value) VALUES('teacher_password',?)", + (hash_password(TEACHER_PASSWORD),), + ) + + def import_roster(self, path: Path) -> tuple[int, int]: + rows = read_xlsx_rows(path) + students = [] + errors = [] + seen = set() + for index, row in enumerate(rows, 2): + student_no = str(row.get("学号", "")).strip() + name = str(row.get("姓名", "")).strip() + class_name = str( + row.get("班级", row.get("班别", row.get("行政班", ""))) + ).strip() + gender = str(row.get("性别", "")).strip() + admission_no = str(row.get("录取编号", "")).strip() + remark = str(row.get("备注", "")).strip() + if not student_no or not name or not class_name: + errors.append(f"第 {index} 行:学号、姓名、班级均不能为空") + continue + if student_no in seen: + errors.append(f"第 {index} 行:学号 {student_no} 重复") + continue + seen.add(student_no) + students.append( + (class_name, student_no, name, gender, admission_no, remark) + ) + if errors: + raise ValueError(";".join(errors[:8])) + if not students: + raise ValueError("Excel 中没有可导入的学生") + + with self.lock, self.connection: + self.connection.executescript( + "DELETE FROM sessions; DELETE FROM group_members; DELETE FROM groups; DELETE FROM students;" + ) + self.connection.executemany( + """INSERT INTO students + (class_name,student_no,name,gender,admission_no,remark,password_hash,must_change_password) + VALUES(?,?,?,?,?,?,?,1)""", + [ + ( + item[0], + item[1], + item[2], + item[3], + item[4], + item[5], + hash_password(item[1]), + ) + for item in students + ], + ) + active = self.get_setting("active_class", conn=self.connection) or students[0][0] + if not any(item[0] == active for item in students): + active = students[0][0] + self.connection.execute( + "UPDATE settings SET value=? WHERE key='active_class'", (active,) + ) + return len(students), len({item[0] for item in students}) + + def get_setting(self, key: str, conn: sqlite3.Connection | None = None) -> str | None: + conn = conn or self.connection + row = conn.execute("SELECT value FROM settings WHERE key=?", (key,)).fetchone() + return None if row is None else row["value"] + + def set_setting(self, key: str, value: str) -> None: + with self.lock, self.connection: + self.connection.execute( + "INSERT INTO settings(key,value) VALUES(?,?) " + "ON CONFLICT(key) DO UPDATE SET value=excluded.value", + (key, value), + ) + + def class_list(self) -> list[dict[str, Any]]: + with self.lock: + rows = self.connection.execute( + """SELECT class_name, COUNT(*) AS student_count + FROM students GROUP BY class_name + ORDER BY CAST(class_name AS REAL), class_name""" + ).fetchall() + active = self.get_setting("active_class") + locked = { + row["class_name"]: bool(row["locked"]) + for row in self.connection.execute("SELECT class_name,locked FROM class_controls") + } + return [ + { + "class_name": row["class_name"], + "student_count": row["student_count"], + "locked": bool(locked.get(row["class_name"], False)), + "active": row["class_name"] == active, + } + for row in rows + ] + + def active_class(self) -> str: + return self.get_setting("active_class") or "" + + def is_locked(self, class_name: str) -> bool: + with self.lock: + row = self.connection.execute( + "SELECT locked FROM class_controls WHERE class_name=?", (class_name,) + ).fetchone() + return bool(row and row["locked"]) + + 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() + now = int(time.time()) + with self.lock, self.connection: + self.connection.execute( + "INSERT INTO sessions(token_hash,role,username,student_id,created_at,expires_at) VALUES(?,?,?,?,?,?)", + (token_hash, role, username, student_id, now, now + SESSION_DAYS * 86400), + ) + self.connection.execute( + "DELETE FROM sessions WHERE expires_at < ?", (now,) + ) + return token + + def get_session(self, token: str | None) -> dict[str, Any] | None: + if not token: + return None + token_hash = hashlib.sha256(token.encode()).hexdigest() + now = int(time.time()) + with self.lock: + row = self.connection.execute( + """SELECT s.token_hash,s.role,s.username,s.student_id,s.expires_at, + st.class_name,st.student_no,st.name,st.must_change_password + FROM sessions s LEFT JOIN students st ON st.id=s.student_id + WHERE s.token_hash=? AND s.expires_at>?""", + (token_hash, now), + ).fetchone() + if row is None: + return None + return dict(row) + + def destroy_session(self, token: str | None) -> None: + if not token: + return + token_hash = hashlib.sha256(token.encode()).hexdigest() + with self.lock, self.connection: + self.connection.execute("DELETE FROM sessions WHERE token_hash=?", (token_hash,)) + + def login_student(self, student_no: str, password: str) -> tuple[dict[str, Any] | None, str]: + student_no = student_no.strip() + active = self.active_class() + with self.lock: + student = self.connection.execute( + "SELECT * FROM students WHERE student_no=?", (student_no,) + ).fetchone() + if student is None or not verify_password(password, student["password_hash"]): + return None, "学号或密码不正确" + if student["class_name"] != active: + return None, f"当前仅开放 {active} 班登录,{student['class_name']} 班暂不能登录" + token = self.create_session("student", student["student_no"], student["id"]) + return { + "token": token, + "student": { + "id": student["id"], + "name": student["name"], + "student_no": student["student_no"], + "class_name": student["class_name"], + "must_change_password": bool(student["must_change_password"]), + }, + }, "登录成功" + + def login_teacher(self, username: str, password: str) -> tuple[dict[str, Any] | None, str]: + with self.lock: + expected_user = self.get_setting("teacher_username") or TEACHER_USER + expected_password = self.get_setting("teacher_password") + if username.strip() != expected_user or not verify_password(password, expected_password): + return None, "教师账号或密码不正确" + token = self.create_session("teacher", expected_user, None) + return {"token": token, "teacher": {"username": expected_user}}, "登录成功" + + def change_student_password( + self, session: dict[str, Any], old_password: str, new_password: str + ) -> tuple[bool, str]: + student_id = session.get("student_id") + if not student_id: + return False, "学生会话无效" + old_password = old_password or "" + new_password = new_password or "" + if len(new_password) < 8: + return False, "新密码至少 8 位" + if new_password == old_password: + return False, "新密码不能与原密码相同" + with self.lock: + student = self.connection.execute( + "SELECT * FROM students WHERE id=?", (student_id,) + ).fetchone() + if student is None or not verify_password(old_password, student["password_hash"]): + return False, "原密码不正确" + if new_password == student["student_no"]: + return False, "新密码不能设置为学号" + with self.connection: + self.connection.execute( + "UPDATE students SET password_hash=?, must_change_password=0 WHERE id=?", + (hash_password(new_password), student_id), + ) + token_hash = hashlib.sha256(session["_token"].encode()).hexdigest() + self.connection.execute( + "DELETE FROM sessions WHERE student_id=? AND token_hash<>?", + (student_id, token_hash), + ) + return True, "密码修改成功" + + def get_student(self, student_id: int) -> dict[str, Any] | None: + with self.lock: + row = self.connection.execute("SELECT * FROM students WHERE id=?", (student_id,)).fetchone() + return dict(row) if row else None + + def student_membership(self, student_id: int) -> dict[str, Any] | None: + with self.lock: + row = self.connection.execute( + """SELECT m.group_id,g.name,g.class_name,g.leader_student_id + FROM group_members m JOIN groups g ON g.id=m.group_id + WHERE m.student_id=?""", + (student_id,), + ).fetchone() + return dict(row) if row else None + + def class_snapshot(self, class_name: str) -> dict[str, Any]: + with self.lock: + groups = self.connection.execute( + """SELECT g.id,g.name,g.leader_student_id,g.created_at, + s.student_no AS leader_student_no,s.name AS leader_name + FROM groups g LEFT JOIN students s ON s.id=g.leader_student_id + WHERE g.class_name=? ORDER BY g.id""", + (class_name,), + ).fetchall() + group_ids = [row["id"] for row in groups] + members: dict[int, list[dict[str, Any]]] = {row["id"]: [] for row in groups} + if group_ids: + placeholders = ",".join("?" for _ in group_ids) + member_rows = self.connection.execute( + f"""SELECT m.group_id,st.id,st.student_no,st.name,st.gender,g.leader_student_id + FROM group_members m JOIN students st ON st.id=m.student_id + JOIN groups g ON g.id=m.group_id + WHERE m.group_id IN ({placeholders}) + ORDER BY CASE WHEN st.id=g.leader_student_id THEN 0 ELSE 1 END,m.joined_at,st.student_no""", + group_ids, + ).fetchall() + pending = [dict(row) for row in member_rows] + for member in pending: + member["is_leader"] = member["id"] == member["leader_student_id"] + member.pop("leader_student_id", None) + members[member["group_id"]].append(member) + + ungrouped_rows = self.connection.execute( + """SELECT id,student_no,name,gender FROM students + WHERE class_name=? AND id NOT IN (SELECT student_id FROM group_members) + ORDER BY student_no""", + (class_name,), + ).fetchall() + total = self.connection.execute( + "SELECT COUNT(*) AS n FROM students WHERE class_name=?", (class_name,) + ).fetchone()["n"] + grouped = self.connection.execute( + """SELECT COUNT(*) AS n FROM group_members m + JOIN students s ON s.id=m.student_id WHERE s.class_name=?""", + (class_name,), + ).fetchone()["n"] + + group_data = [] + for row in groups: + group_data.append( + { + **dict(row), + "locked": self.is_locked(class_name), + "member_count": len(members[row["id"]]), + "full": len(members[row["id"]]) >= GROUP_LIMIT, + "members": members[row["id"]], + } + ) + return { + "class_name": class_name, + "locked": self.is_locked(class_name), + "group_limit": GROUP_LIMIT, + "groups": group_data, + "ungrouped": [dict(row) for row in ungrouped_rows], + "statistics": {"total_students": total, "grouped_students": grouped, "group_count": len(group_data)}, + } + + def create_group(self, student_id: int, group_name: str) -> tuple[bool, str, int | None]: + student = self.get_student(student_id) + if student is None: + return False, "学生不存在", None + if self.is_locked(student["class_name"]): + return False, "老师已停止分组操作,当前只能查看", None + current = self.student_membership(student_id) + if current is not None: + return False, "你已加入其他组,不能创建组;请先退出当前组", None + if student["must_change_password"]: + return False, "首次登录必须先修改密码", None + group_name = group_name.strip() or "未命名小组" + with self.lock, self.connection: + count = self.connection.execute( + "SELECT COUNT(*) AS n FROM groups WHERE class_name=?", (student["class_name"],) + ).fetchone()["n"] + cursor = self.connection.execute( + "INSERT INTO groups(class_name,name,leader_student_id) VALUES(?,?,?)", + (student["class_name"], group_name, student_id), + ) + group_id = int(cursor.lastrowid) + self.connection.execute( + "INSERT INTO group_members(group_id,student_id) VALUES(?,?)", + (group_id, student_id), + ) + return True, f"“{group_name}”创建成功,你是组长", group_id + + def join_group(self, student_id: int, group_id: int, confirmed: bool) -> tuple[bool, str]: + student = self.get_student(student_id) + if student is None: + return False, "学生不存在" + if student["must_change_password"]: + return False, "首次登录必须先修改密码" + if self.is_locked(student["class_name"]): + return False, "老师已停止分组操作,当前只能查看" + with self.lock: + group = self.connection.execute( + "SELECT * FROM groups WHERE id=?", (group_id,) + ).fetchone() + current = self.connection.execute( + """SELECT m.group_id,g.name,g.leader_student_id + FROM group_members m JOIN groups g ON g.id=m.group_id WHERE m.student_id=?""", + (student_id,), + ).fetchone() + if group is None: + return False, "目标小组不存在" + if group["class_name"] != student["class_name"]: + return False, "不能加入其他班级的小组" + if current and current["group_id"] == group_id: + return False, "你已经在这个组里" + member_count = self.connection.execute( + "SELECT COUNT(*) AS n FROM group_members WHERE group_id=?", (group_id,) + ).fetchone()["n"] + if member_count >= GROUP_LIMIT: + return False, f"该组已有 {GROUP_LIMIT} 人,不能再加入新成员" + if current and not confirmed: + role = "组长,原组会被解散" if current["leader_student_id"] == student_id else "成员" + return False, f"你当前是“{current['name']}”的{role}。加入新组前必须退出原组;确认后才会执行。" + + with self.lock, self.connection: + if current: + if current["leader_student_id"] == student_id: + self.connection.execute("DELETE FROM groups WHERE id=?", (current["group_id"],)) + else: + self.connection.execute( + "DELETE FROM group_members WHERE student_id=?", (student_id,) + ) + self.connection.execute( + "INSERT INTO group_members(group_id,student_id) VALUES(?,?)", + (group_id, student_id), + ) + return True, "加入成功" + + def leave_group(self, student_id: int, confirmed: bool) -> tuple[bool, str]: + if self.get_student(student_id) is None: + return False, "学生不存在" + current = self.student_membership(student_id) + if current is None: + return False, "你当前没有加入任何小组" + if self.is_locked(current["class_name"]): + return False, "老师已停止分组操作,当前只能查看" + is_leader = current["leader_student_id"] == student_id + if not confirmed: + if is_leader: + return False, "你是组长。退出时你创建的组将被解散,所有成员都会变为未分组;确认后才会执行。" + return False, "退出后可以再加入其他组;确认后才会执行。" + with self.lock, self.connection: + if is_leader: + self.connection.execute("DELETE FROM groups WHERE id=?", (current["group_id"],)) + else: + self.connection.execute( + "DELETE FROM group_members WHERE group_id=? AND student_id=?", + (current["group_id"], student_id), + ) + return True, "组长已解散小组" if is_leader else "已退出小组" + + def switch_class(self, class_name: str) -> tuple[bool, str]: + with self.lock: + row = self.connection.execute( + "SELECT COUNT(*) AS n FROM students WHERE class_name=?", (class_name,) + ).fetchone() + if row["n"] == 0: + return False, "班级不存在" + self.set_setting("active_class", class_name) + return True, f"已切换到 {class_name} 班,仅该班学生可登录" + + def set_locked(self, class_name: str, locked: bool) -> tuple[bool, str]: + with self.lock: + exists = self.connection.execute( + "SELECT 1 FROM students WHERE class_name=? LIMIT 1", (class_name,) + ).fetchone() + if not exists: + return False, "班级不存在" + with self.lock, self.connection: + self.connection.execute( + """INSERT INTO class_controls(class_name,locked) VALUES(?,?) + ON CONFLICT(class_name) DO UPDATE SET locked=excluded.locked""", + (class_name, int(locked)), + ) + return True, ("已停止学生操作" if locked else "已恢复学生操作") + + def _require_teacher_group(self, class_name: str) -> tuple[bool, str, sqlite3.Row | None]: + if not any(item["class_name"] == class_name for item in self.class_list()): + return False, "班级不存在", None + return True, "", None + + def teacher_create_group(self, class_name: str, name: str) -> tuple[bool, str]: + if not self._require_teacher_group(class_name)[0]: + return False, "班级不存在" + name = name.strip() or "未命名小组" + with self.lock, self.connection: + self.connection.execute( + "INSERT INTO groups(class_name,name,leader_student_id) VALUES(?,?,NULL)", + (class_name, name), + ) + return True, f"小组“{name}”已创建" + + def teacher_rename_group(self, group_id: int, name: str) -> tuple[bool, str]: + name = name.strip() + if not name: + return False, "组名不能为空" + with self.lock, self.connection: + cursor = self.connection.execute("UPDATE groups SET name=? WHERE id=?", (name, group_id)) + if cursor.rowcount == 0: + return False, "小组不存在" + return True, "组名已保存" + + def teacher_set_leader(self, group_id: int, student_id: int | None) -> tuple[bool, str]: + with self.lock, self.connection: + group = self.connection.execute("SELECT * FROM groups WHERE id=?", (group_id,)).fetchone() + if group is None: + return False, "小组不存在" + if student_id is None: + self.connection.execute( + "UPDATE groups SET leader_student_id=NULL WHERE id=?", (group_id,) + ) + return True, "已取消组长" + member = self.connection.execute( + "SELECT 1 FROM group_members WHERE group_id=? AND student_id=?", + (group_id, student_id), + ).fetchone() + if not member: + return False, "组长必须是该组成员" + self.connection.execute( + "UPDATE groups SET leader_student_id=? WHERE id=?", (student_id, group_id) + ) + return True, "组长已设置" + + def teacher_add_member(self, group_id: int, student_id: int) -> tuple[bool, str]: + with self.lock, self.connection: + group = self.connection.execute("SELECT * FROM groups WHERE id=?", (group_id,)).fetchone() + student = self.connection.execute("SELECT * FROM students WHERE id=?", (student_id,)).fetchone() + if group is None or student is None: + return False, "小组或学生不存在" + if group["class_name"] != student["class_name"]: + return False, "不能跨班调整" + current_group = self.connection.execute( + "SELECT group_id FROM group_members WHERE student_id=?", (student_id,) + ).fetchone() + if current_group and current_group["group_id"] == group_id: + return False, "学生已在该组" + count = self.connection.execute( + "SELECT COUNT(*) AS n FROM group_members WHERE group_id=?", (group_id,) + ).fetchone()["n"] + if count >= GROUP_LIMIT: + return False, f"该组已有 {GROUP_LIMIT} 人;请先移出一人" + if current_group: + self.connection.execute( + "DELETE FROM group_members WHERE student_id=?", (student_id,) + ) + self.connection.execute( + "UPDATE groups SET leader_student_id=NULL WHERE id=? AND leader_student_id=?", + (current_group["group_id"], student_id), + ) + self.connection.execute( + "INSERT INTO group_members(group_id,student_id) VALUES(?,?)", + (group_id, student_id), + ) + return True, "已加入小组" + + def teacher_remove_member(self, student_id: int) -> tuple[bool, str]: + with self.lock, self.connection: + membership = self.connection.execute( + "SELECT * FROM group_members WHERE student_id=?", (student_id,) + ).fetchone() + if not membership: + return False, "学生当前未分组" + self.connection.execute( + "DELETE FROM group_members WHERE student_id=?", (student_id,) + ) + self.connection.execute( + """UPDATE groups SET leader_student_id=NULL + WHERE leader_student_id=?""", (student_id,) + ) + return True, "已移出小组" + + def teacher_disband_group(self, group_id: int) -> tuple[bool, str]: + with self.lock, self.connection: + cursor = self.connection.execute("DELETE FROM groups WHERE id=?", (group_id,)) + if cursor.rowcount == 0: + return False, "小组不存在" + return True, "小组已解散" + + def export_groups(self, class_name: str | None = None) -> tuple[bytes, str]: + with self.lock: + if class_name: + class_filter = "WHERE g.class_name=?" + params: tuple[Any, ...] = (class_name,) + else: + class_filter = "" + params = () + class_name = None + group_rows = self.connection.execute( + f"""SELECT g.id,g.class_name,g.name,s1.student_no leader_no,s1.name leader_name + FROM groups g LEFT JOIN students s1 ON s1.id=g.leader_student_id + {class_filter} ORDER BY g.class_name,g.id""", + params, + ).fetchall() + member_rows = self.connection.execute( + f"""SELECT m.group_id,s.student_no,s.name,s.gender,s.class_name + FROM group_members m JOIN groups g ON g.id=m.group_id + JOIN students s ON s.id=m.student_id + {class_filter} + ORDER BY g.class_name,g.id,CASE WHEN s.id=g.leader_student_id THEN 0 ELSE 1 END,s.student_no""", + params, + ).fetchall() + ungrouped_rows = self.connection.execute( + f"""SELECT s.class_name,s.student_no,s.name,s.gender + FROM students s WHERE s.id NOT IN (SELECT student_id FROM group_members) + {'AND s.class_name=?' if class_name else ''} + ORDER BY s.class_name,s.student_no""", + ((class_name,) if class_name else ()), + ).fetchall() + + by_group: dict[int, list[sqlite3.Row]] = {} + for row in member_rows: + by_group.setdefault(row["group_id"], []).append(row) + summary_headers = ["序号", "班级", "组名", "组内序号", "学号", "姓名", "性别", "是否组长"] + summary_records = [] + row_number = 0 + for group in group_rows: + for member_number, member in enumerate(by_group.get(group["id"], []), 1): + row_number += 1 + summary_records.append( + { + "序号": row_number, + "班级": member["class_name"], + "组名": group["name"], + "组内序号": member_number, + "学号": member["student_no"], + "姓名": member["name"], + "性别": member["gender"], + "是否组长": "是" if member["student_no"] == group["leader_no"] else "否", + } + ) + if not summary_records: + summary_records.append({ + "序号": "", "班级": "", "组名": "暂无分组", "组内序号": "", + "学号": "", "姓名": "", "性别": "", "是否组长": "", + }) + + ungrouped_headers = ["序号", "班级", "学号", "姓名", "性别", "说明"] + ungrouped_records = [ + { + "序号": index, + "班级": row["class_name"], + "学号": row["student_no"], + "姓名": row["name"], + "性别": row["gender"], + "说明": "未分组", + } + for index, row in enumerate(ungrouped_rows, 1) + ] + if not ungrouped_records: + ungrouped_records.append({ + "序号": "", "班级": "", "学号": "", "姓名": "", "性别": "", "说明": "全部学生均已分组" + }) + + title = f"{class_name}班" if class_name else "全部班级" + xlsx = write_xlsx( + [ + (f"{title}分组", summary_headers, summary_records), + ("未分组学生", ungrouped_headers, ungrouped_records), + ] + ) + return xlsx, export_filename("学生分组", class_name) + + +STORE = GroupStore(DB_PATH) + + +def json_response(status: int, payload: Any, cookies: list[dict[str, Any]] | None = None): + from http.server import BaseHTTPRequestHandler + + def send(handler: BaseHTTPRequestHandler): + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + handler.send_response(status) + handler.send_header("Content-Type", "application/json; charset=utf-8") + handler.send_header("Cache-Control", "no-store") + handler.send_header("Content-Length", str(len(body))) + for cookie in cookies or []: + handler.send_header("Set-Cookie", cookie["value"]) + handler.end_headers() + handler.wfile.write(body) + + return send + + +def make_session_cookie(token: str) -> str: + return f"GROUP_SESSION={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age={SESSION_DAYS * 86400}" + + +def clear_session_cookie() -> str: + return "GROUP_SESSION=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0" + + +class GroupRequestHandler: + server_version = "StudentGroupSystem/1.0" + + def __init__(self, handler): + self.handler = handler + + def cookies(self) -> dict[str, str]: + header = self.handler.headers.get("Cookie", "") + result = {} + for part in header.split(";"): + if "=" in part: + key, value = part.strip().split("=", 1) + result[key] = urllib.parse.unquote(value) + return result + + def token(self) -> str | None: + return self.cookies().get("GROUP_SESSION") + + def session(self) -> dict[str, Any] | None: + result = STORE.get_session(self.token()) + if result: + result["_token"] = self.token() + return result + + def read_json(self) -> dict[str, Any]: + length = int(self.handler.headers.get("Content-Length", "0") or 0) + if length <= 0: + return {} + if length > 1024 * 1024: + raise ValueError("请求体过大") + raw = self.handler.rfile.read(length) + return json.loads(raw.decode("utf-8")) + + def require_teacher(self): + session = self.session() + if not session or session["role"] != "teacher": + return None, json_response(HTTPStatus.UNAUTHORIZED, {"ok": False, "message": "请先登录教师后台"}) + return session, None + + def require_student(self): + session = self.session() + if not session or session["role"] != "student" or not session["student_id"]: + return None, json_response(HTTPStatus.UNAUTHORIZED, {"ok": False, "message": "请先登录"}) + return session, None + + def handle_api(self, action: str, method: str): + try: + return self.dispatch_api(action, method) + except (ValueError, sqlite3.IntegrityError, sqlite3.OperationalError) as exc: + LOG.warning("API error %s %s: %s", method, action, exc) + return json_response(HTTPStatus.BAD_REQUEST, {"ok": False, "message": str(exc)}) + except Exception: + LOG.exception("Unexpected API error") + return json_response(HTTPStatus.INTERNAL_SERVER_ERROR, {"ok": False, "message": "服务器内部错误"}) + + def dispatch_api(self, action: str, method: str): + if action == "login" and method == "POST": + data = self.read_json() + result, message = STORE.login_student(data.get("username", ""), data.get("password", "")) + if not result: + return json_response(HTTPStatus.UNAUTHORIZED, {"ok": False, "message": message}) + return json_response( + HTTPStatus.OK, + {"ok": True, "message": message, "user": result["student"]}, + [{"value": make_session_cookie(result["token"])}], + ) + + if action == "teacher-login" and method == "POST": + data = self.read_json() + result, message = STORE.login_teacher(data.get("username", ""), data.get("password", "")) + if not result: + return json_response(HTTPStatus.UNAUTHORIZED, {"ok": False, "message": message}) + return json_response( + HTTPStatus.OK, + {"ok": True, "message": message, "user": result["teacher"]}, + [{"value": make_session_cookie(result["token"])}], + ) + + if action == "logout" and method == "POST": + STORE.destroy_session(self.token()) + return json_response(HTTPStatus.OK, {"ok": True, "message": "已退出"}, [{"value": clear_session_cookie()}]) + + if action == "state" and method == "GET": + session = self.session() + if not session: + return json_response(HTTPStatus.OK, {"ok": True, "authenticated": False}) + active = STORE.active_class() + common = { + "authenticated": True, + "role": session["role"], + "active_class": active, + "classes": STORE.class_list() if session["role"] == "teacher" else [], + } + if session["role"] == "teacher": + selected = self.handler.path.split("class=", 1)[1].split("&", 1)[0] if "class=" in self.handler.path else active + selected = urllib.parse.unquote(selected) + if selected and selected != active: + # Teacher can preview any class, but active_class remains explicit. + return json_response( + HTTPStatus.OK, + { + **common, + "view_class": selected, + "snapshot": STORE.class_snapshot(selected), + }, + ) + return json_response( + HTTPStatus.OK, + { + **common, + "view_class": active, + "teacher": {"username": session["username"]}, + "snapshot": STORE.class_snapshot(active), + }, + ) + + student = STORE.get_student(session["student_id"]) + if student is None: + return json_response(HTTPStatus.OK, {"ok": True, "authenticated": False}) + membership = STORE.student_membership(student["id"]) + snapshot = STORE.class_snapshot(student["class_name"]) + return json_response( + HTTPStatus.OK, + { + **common, + "user": { + "id": student["id"], + "name": student["name"], + "student_no": student["student_no"], + "class_name": student["class_name"], + "must_change_password": bool(student["must_change_password"]), + }, + "membership": membership, + "snapshot": snapshot, + }, + ) + + if action == "change-password" and method == "POST": + session, error = self.require_student() + if error: + return error + data = self.read_json() + ok, message = STORE.change_student_password( + session, data.get("old_password", ""), data.get("new_password", "") + ) + status = HTTPStatus.OK if ok else HTTPStatus.BAD_REQUEST + return json_response(status, {"ok": ok, "message": message}) + + if action == "groups" and method == "POST": + session, error = self.require_student() + if error: + return error + student = STORE.get_student(session["student_id"]) + if student and student["must_change_password"]: + return json_response(HTTPStatus.FORBIDDEN, {"ok": False, "message": "首次登录必须先修改密码"}) + data = self.read_json() + ok, message, _ = STORE.create_group(session["student_id"], data.get("name", "")) + return json_response( + HTTPStatus.OK if ok else HTTPStatus.BAD_REQUEST, + {"ok": ok, "message": message}, + ) + + if action == "join" and method == "POST": + session, error = self.require_student() + if error: + return error + data = self.read_json() + ok, message = STORE.join_group( + session["student_id"], int(data.get("group_id", 0)), bool(data.get("confirmed")) + ) + return json_response( + HTTPStatus.OK if ok else HTTPStatus.BAD_REQUEST, + {"ok": ok, "message": message, "require_confirm": (not ok and "确认后才会执行" in message)}, + ) + + if action == "leave" and method == "POST": + session, error = self.require_student() + if error: + return error + data = self.read_json() + ok, message = STORE.leave_group(session["student_id"], bool(data.get("confirmed"))) + return json_response( + HTTPStatus.OK if ok else HTTPStatus.BAD_REQUEST, + {"ok": ok, "message": message, "require_confirm": (not ok and "确认后才会执行" in message)}, + ) + + if action == "teacher/class" and method == "POST": + _, error = self.require_teacher() + if error: + return error + data = self.read_json() + ok, message = STORE.switch_class(str(data.get("class_name", ""))) + return json_response(HTTPStatus.OK if ok else HTTPStatus.BAD_REQUEST, {"ok": ok, "message": message}) + + if action == "teacher/lock" and method == "POST": + _, error = self.require_teacher() + if error: + return error + data = self.read_json() + class_name = str(data.get("class_name", STORE.active_class())) + ok, message = STORE.set_locked(class_name, bool(data.get("locked"))) + return json_response(HTTPStatus.OK if ok else HTTPStatus.BAD_REQUEST, {"ok": ok, "message": message}) + + if action == "teacher/create-group" and method == "POST": + _, error = self.require_teacher() + if error: + return error + data = self.read_json() + ok, message = STORE.teacher_create_group( + str(data.get("class_name", STORE.active_class())), data.get("name", "") + ) + return json_response(HTTPStatus.OK if ok else HTTPStatus.BAD_REQUEST, {"ok": ok, "message": message}) + + if action == "teacher/rename-group" and method == "POST": + _, error = self.require_teacher() + if error: + return error + data = self.read_json() + ok, message = STORE.teacher_rename_group(int(data.get("group_id", 0)), data.get("name", "")) + return json_response(HTTPStatus.OK if ok else HTTPStatus.BAD_REQUEST, {"ok": ok, "message": message}) + + if action == "teacher/set-leader" and method == "POST": + _, error = self.require_teacher() + if error: + return error + data = self.read_json() + student_id = data.get("student_id") + ok, message = STORE.teacher_set_leader( + int(data.get("group_id", 0)), int(student_id) if student_id else None + ) + return json_response(HTTPStatus.OK if ok else HTTPStatus.BAD_REQUEST, {"ok": ok, "message": message}) + + if action == "teacher/add-member" and method == "POST": + _, error = self.require_teacher() + if error: + return error + data = self.read_json() + ok, message = STORE.teacher_add_member(int(data.get("group_id", 0)), int(data.get("student_id", 0))) + return json_response(HTTPStatus.OK if ok else HTTPStatus.BAD_REQUEST, {"ok": ok, "message": message}) + + if action == "teacher/remove-member" and method == "POST": + _, error = self.require_teacher() + if error: + return error + data = self.read_json() + ok, message = STORE.teacher_remove_member(int(data.get("student_id", 0))) + return json_response(HTTPStatus.OK if ok else HTTPStatus.BAD_REQUEST, {"ok": ok, "message": message}) + + if action == "teacher/disband" and method == "POST": + _, error = self.require_teacher() + if error: + return error + data = self.read_json() + ok, message = STORE.teacher_disband_group(int(data.get("group_id", 0))) + return json_response(HTTPStatus.OK if ok else HTTPStatus.BAD_REQUEST, {"ok": ok, "message": message}) + + if action == "teacher/import" and method == "POST": + _, error = self.require_teacher() + if error: + return error + length = int(self.handler.headers.get("Content-Length", "0") or 0) + if length <= 0 or length > 10 * 1024 * 1024: + return json_response(HTTPStatus.BAD_REQUEST, {"ok": False, "message": "Excel 文件大小无效"}) + raw = self.handler.rfile.read(length) + temp_path = BASE_DIR / ".upload-roster.xlsx" + temp_path.write_bytes(raw) + try: + student_count, class_count = STORE.import_roster(temp_path) + finally: + temp_path.unlink(missing_ok=True) + return json_response( + HTTPStatus.OK, + {"ok": True, "message": f"导入成功:{student_count} 名学生,{class_count} 个班。所有原分组已重置。"}, + ) + + if action == "teacher/export" and method == "GET": + _, error = self.require_teacher() + if error: + return error + query = urllib.parse.parse_qs(urllib.parse.urlparse(self.handler.path).query) + class_name = query.get("class", [None])[0] + if class_name: + class_name = urllib.parse.unquote(class_name) + content, filename = STORE.export_groups(class_name) + + def send(handler): + handler.send_response(HTTPStatus.OK) + handler.send_header("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + quoted = urllib.parse.quote(filename) + handler.send_header("Content-Disposition", f"attachment; filename*=UTF-8''{quoted}") + handler.send_header("Content-Length", str(len(content))) + handler.send_header("Cache-Control", "no-store") + handler.end_headers() + handler.wfile.write(content) + + return send + + return json_response(HTTPStatus.NOT_FOUND, {"ok": False, "message": "接口不存在"}) + + +def route(handler): + parsed = urllib.parse.urlparse(handler.path) + path = parsed.path + request = GroupRequestHandler(handler) + if path.startswith("/api/"): + result = request.handle_api(path[len("/api/"):], handler.command) + if callable(result): + result(handler) + return + + if path in ("/", "/index.html", "/student"): + return serve_file(handler, STATIC_DIR / "index.html") + if path in ("/teacher", "/admin"): + return serve_file(handler, STATIC_DIR / "teacher.html") + safe_path = (STATIC_DIR / urllib.parse.unquote(path).lstrip("/")).resolve() + try: + safe_path.relative_to(STATIC_DIR.resolve()) + except ValueError: + return send_text(handler, HTTPStatus.NOT_FOUND, "Not Found") + return serve_file(handler, safe_path) + + +def serve_file(handler, path: Path): + from http.server import SimpleHTTPRequestHandler + + path = path if path.is_absolute() else STATIC_DIR / path + if not path.is_file(): + return send_text(handler, HTTPStatus.NOT_FOUND, "Not Found") + suffix = path.suffix.lower() + if suffix in (".html", ".htm"): + guess = "text/html; charset=utf-8" + elif suffix == ".css": + guess = "text/css; charset=utf-8" + elif suffix in (".js", ".mjs"): + guess = "text/javascript; charset=utf-8" + elif suffix == ".svg": + guess = "image/svg+xml" + elif suffix == ".png": + guess = "image/png" + else: + guess = "application/octet-stream" + content = path.read_bytes() + handler.send_response(HTTPStatus.OK) + handler.send_header("Content-Type", guess or "application/octet-stream") + handler.send_header("Content-Length", str(len(content))) + handler.send_header("Cache-Control", "no-cache") + handler.end_headers() + handler.wfile.write(content) + + +def send_text(handler, status: int, text: str): + body = text.encode("utf-8") + handler.send_response(status) + handler.send_header("Content-Type", "text/plain; charset=utf-8") + handler.send_header("Content-Length", str(len(body))) + handler.end_headers() + handler.wfile.write(body) + + +class Handler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + route(self) + + def do_POST(self): + route(self) + + def do_HEAD(self): + route(self) + + def log_message(self, format_string, *args): + LOG.info("%s - %s", self.address_string(), format_string % args) + + +def main() -> None: + DB_PATH.parent.mkdir(parents=True, exist_ok=True) + if not any(STORE.class_list()): + if XLSX_PATH.exists(): + student_count, class_count = STORE.import_roster(XLSX_PATH) + LOG.info("已导入 %s: %d 名学生 / %d 个班", XLSX_PATH, student_count, class_count) + else: + LOG.warning("未找到 %s,请先通过教师后台上传学生名单", XLSX_PATH) + + host = os.environ.get("HOST", "0.0.0.0") + port = int(os.environ.get("PORT", "8000")) + server = http.server.ThreadingHTTPServer((host, port), Handler) + LOG.info("学生分组系统已启动:http://0.0.0.0:%d", port) + LOG.info("学生入口:http://<老师电脑IP>:%d/ 教师入口:http://<老师电脑IP>:%d/teacher", port, port) + try: + server.serve_forever() + except KeyboardInterrupt: + LOG.info("服务停止") + finally: + server.server_close() + + +if __name__ == "__main__": + main() diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..bf87bbf --- /dev/null +++ b/start.sh @@ -0,0 +1,4 @@ +#!/bin/sh +set -eu +cd "$(dirname "$0")" +exec python3 app.py diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..9a95b89 --- /dev/null +++ b/static/index.html @@ -0,0 +1,91 @@ + + + + + + 学生自助分组 + + + +
+
组学生自助分组系统
+ +
+ +
+ + + +
+ + + + + + + diff --git a/static/shared.js b/static/shared.js new file mode 100644 index 0000000..7f092e9 --- /dev/null +++ b/static/shared.js @@ -0,0 +1,53 @@ +async function api(path, options = {}) { + const config = { + method: options.method || 'GET', + headers: {}, + credentials: 'same-origin' + }; + if (options.json !== undefined) { + config.headers['Content-Type'] = 'application/json'; + config.body = JSON.stringify(options.json); + } else if (options.body) { + config.body = options.body; + } + const response = await fetch(`/api/${path}`, config); + let data; + try { + data = await response.json(); + } catch (_) { + data = { ok: false, message: '服务器响应异常,请刷新后重试' }; + } + if (!response.ok && !data.message) data.message = `请求失败(${response.status})`; + return data; +} + +function toast(message, isError = false) { + let element = document.querySelector('#toast'); + if (!element) { + element = document.createElement('div'); + element.id = 'toast'; + element.className = 'toast'; + document.body.appendChild(element); + } + element.textContent = message; + element.classList.toggle('error', Boolean(isError)); + element.classList.add('show'); + clearTimeout(element.timer); + element.timer = setTimeout(() => element.classList.remove('show'), 3800); +} + +function text(value) { + return String(value ?? '').replace(/[&<>"']/g, ch => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' + }[ch])); +} + +function requireConfirm(message) { + return window.confirm(message); +} + +function setButtonsDisabled(root, disabled) { + root.querySelectorAll('button[data-working]').forEach(button => { + button.disabled = disabled || button.dataset.alwaysDisabled === '1'; + }); +} diff --git a/static/student.js b/static/student.js new file mode 100644 index 0000000..d34aafe --- /dev/null +++ b/static/student.js @@ -0,0 +1,211 @@ +const state = { user: null, membership: null, snapshot: null }; + +const el = id => document.getElementById(id); + +function renderUnauthenticated() { + el('loginArea').classList.remove('hidden'); + el('mainArea').classList.add('hidden'); + el('userToolbar').classList.add('hidden'); + el('passwordModal').classList.add('hidden'); +} + +function renderStatistics() { + const stats = state.snapshot.statistics; + const remaining = state.snapshot.statistics.total_students - state.snapshot.statistics.grouped_students; + el('statistics').innerHTML = [ + { label: '班级总人数', value: stats.total_students }, + { label: '已分组人数', value: stats.grouped_students }, + { label: '未分组人数', value: remaining } + ].map(item => `
${text(item.value)}${item.label}
`).join(''); +} + +function renderIdentity() { + const user = state.user; + el('userChip').textContent = `${user.class_name}班 · ${user.name} · ${user.student_no}`; + el('identity').innerHTML = [ + ['姓名', user.name], ['学号', user.student_no], ['班级', `${user.class_name}班`], ['登录状态', '已登录'] + ].map(row => `${text(row[0])}${text(row[1])}`).join(''); + el('classStatus').innerHTML = [ + `${text(user.class_name)}班`, + `${state.snapshot.locked ? '老师已停止操作' : '分组进行中'}` + ].join(''); +} + +function renderMyGroup() { + const target = el('myGroupAction'); + const membership = state.membership; + if (!membership) { + target.innerHTML = ` +
+ +
+ + +
+

创建后你是组长。每组成员(包括组长)最多 ${state.snapshot.group_limit} 人。已加入别人的组时不能创建。

+
`; + el('createForm').addEventListener('submit', submitCreate); + return; + } + + const isLeader = membership.leader_student_id === state.user.id; + target.innerHTML = ` +
+ 当前组:${text(membership.name)} + ${isLeader ? '我是组长' : '我是组员'} +
+
+ +
+

${isLeader + ? '组长退出会解散整个组,所有成员都会变为未分组。' + : '退出后可以加入其他组。已加入其他组时,不能创建新组。'}

`; + el('leaveButton').addEventListener('click', submitLeave); +} + +function memberLine(member) { + return `${text(member.student_no)} · ${text(member.name)}${member.is_leader ? ' 组长' : ''}`; +} + +function renderGroups() { + const grid = el('groupsGrid'); + if (!state.snapshot.groups.length) { + grid.innerHTML = '
当前班级还没有小组。你可以创建第一个组。
'; + return; + } + grid.innerHTML = state.snapshot.groups.map(group => { + const own = state.membership && state.membership.group_id === group.id; + const locked = state.snapshot.locked; + const full = group.full; + let disabledReason = ''; + if (locked) disabledReason = '老师已停止分组操作,当前只能查看分组结果'; + else if (full) disabledReason = `该组已有 ${state.snapshot.group_limit} 人,不能再加入新成员`; + return ` +
+
+
${text(group.name)}
+ ${group.member_count}/${state.snapshot.group_limit} 人 +
+
    + ${group.members.length ? group.members.map(member => `
  • ${memberLine(member)}
  • `).join('') : '
  • 暂无成员
  • '} +
+ ${own ? '我的当前组' + : ``} +
`; + }).join(''); + grid.querySelectorAll('button[data-group]').forEach(button => button.addEventListener('click', () => { + if (button.dataset.reason) { + toast(button.dataset.reason, true); + return; + } + submitJoin(Number(button.dataset.group)); + })); +} + +function renderState() { + if (!state.user) return renderUnauthenticated(); + el('loginArea').classList.add('hidden'); + el('mainArea').classList.remove('hidden'); + el('userToolbar').classList.remove('hidden'); + renderStatistics(); + renderIdentity(); + renderMyGroup(); + renderGroups(); + if (state.user.must_change_password) { + el('passwordModal').classList.remove('hidden'); + } else { + el('passwordModal').classList.add('hidden'); + } +} + +async function loadState() { + const result = await api('state'); + if (!result.ok || !result.authenticated) return renderUnauthenticated(); + state.user = result.user; + state.membership = result.membership; + state.snapshot = result.snapshot; + renderState(); +} + +async function submitLogin(event) { + event.preventDefault(); + setButtonsDisabled(el('loginForm'), true); + const result = await api('login', { + method: 'POST', + json: { username: el('username').value.trim(), password: el('password').value } + }); + setButtonsDisabled(el('loginForm'), false); + el('loginNotice').textContent = result.ok ? '' : result.message; + if (result.ok) { + toast(result.message); + await loadState(); + } +} + +async function submitCreate(event) { + event.preventDefault(); + const result = await api('groups', { method: 'POST', json: { name: el('groupName').value } }); + toast(result.message, !result.ok); + if (result.ok) await loadState(); +} + +async function submitJoin(groupId, confirmed = false) { + const result = await api('join', { method: 'POST', json: { group_id: groupId, confirmed } }); + if (!result.ok && result.require_confirm) { + if (requireConfirm(result.message)) return submitJoin(groupId, true); + toast('已取消加入'); + return; + } + toast(result.message, !result.ok); + if (result.ok) await loadState(); +} + +async function submitLeave(confirmed = false) { + const result = await api('leave', { method: 'POST', json: { confirmed } }); + if (!result.ok && result.require_confirm) { + if (requireConfirm(result.message)) return submitLeave(true); + toast('已取消操作'); + return; + } + toast(result.message, !result.ok); + if (result.ok) await loadState(); +} + +async function submitPassword(prefix) { + const oldInput = el(`${prefix}Old`); + const newInput = el(`${prefix}New`); + const confirmInput = el(`${prefix}Confirm`); + const form = prefix === 'first' ? el('firstPasswordForm') : el('passwordForm'); + const notice = prefix === 'first' ? el('firstPasswordNotice') : null; + if (newInput.value !== confirmInput.value) { + if (notice) notice.textContent = '两次输入的新密码不一致'; + else toast('两次输入的新密码不一致', true); + return; + } + setButtonsDisabled(form, true); + const result = await api('change-password', { + method: 'POST', + json: { old_password: oldInput.value, new_password: newInput.value } + }); + setButtonsDisabled(form, false); + if (notice) notice.textContent = result.ok ? '' : result.message; + toast(result.message, !result.ok); + if (result.ok) { + form.reset(); + await loadState(); + } +} + +async function logout() { + await api('logout', { method: 'POST' }); + state.user = null; + renderUnauthenticated(); +} + +el('loginForm').addEventListener('submit', submitLogin); +el('logoutButton').addEventListener('click', logout); +el('logoutFromModal').addEventListener('click', logout); +el('passwordForm').addEventListener('submit', event => { event.preventDefault(); submitPassword(''); }); +el('firstPasswordForm').addEventListener('submit', event => { event.preventDefault(); submitPassword('first'); }); + +loadState(); diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..eae27dd --- /dev/null +++ b/static/style.css @@ -0,0 +1,156 @@ +:root { + --bg: #f3f6fb; + --panel: #ffffff; + --text: #18212f; + --muted: #64748b; + --primary: #2563eb; + --primary-dark: #1d4ed8; + --success: #059669; + --danger: #dc2626; + --warning: #d97706; + --border: #dbe2ea; + --shadow: 0 10px 30px rgba(15, 23, 42, .08); +} + +* { box-sizing: border-box; } +html, body { margin: 0; min-height: 100%; } +body { + color: var(--text); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; + background: radial-gradient(circle at top left, #e0edff 0, var(--bg) 38%); +} + +.topbar { + min-height: 64px; + padding: 14px max(20px, env(safe-area-inset-left)); + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + background: rgba(255,255,255,.92); + border-bottom: 1px solid var(--border); + position: sticky; + top: 0; + z-index: 10; +} +.brand { display: flex; align-items: center; gap: 10px; font-weight: 750; font-size: 19px; } +.brand-mark { + width: 34px; height: 34px; border-radius: 9px; color: white; + display: grid; place-items: center; background: linear-gradient(135deg,#2563eb,#7c3aed); +} +.toolbar { display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex-wrap: wrap; } + +.page { max-width: 1280px; margin: 24px auto 60px; padding: 0 20px; } +.login-wrap { min-height: calc(100vh - 80px); display: grid; place-items: center; } +.login-card, .panel { + width: min(430px, calc(100vw - 32px)); + background: var(--panel); border: 1px solid var(--border); + border-radius: 16px; padding: 28px; box-shadow: var(--shadow); +} +.panel { width: 100%; margin-bottom: 18px; } +.title { margin: 0 0 6px; font-size: 24px; } +.subtitle { margin: 0 0 22px; color: var(--muted); line-height: 1.6; } + +label { display: block; margin: 14px 0 6px; font-size: 14px; font-weight: 650; } +input, select { + width: 100%; height: 42px; border: 1px solid var(--border); border-radius: 8px; + padding: 0 12px; font-size: 15px; color: var(--text); background: #fff; outline: none; +} +input:focus, select:focus { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(37,99,235,.13); } +button { + height: 38px; border: 0; border-radius: 8px; padding: 0 14px; font-size: 14px; + cursor: pointer; color: white; background: var(--primary); transition: .15s; +} +button:hover:not(:disabled) { background: var(--primary-dark); transform: translateY(-1px); } +button:disabled { cursor: not-allowed; opacity: .5; transform: none; } +button.full { width: 100%; height: 42px; margin-top: 18px; font-weight: 650; } +button.ghost { color: var(--text); background: #eef2f7; } +button.success { background: var(--success); } +button.danger { background: var(--danger); } +button.warning { background: var(--warning); } +button.small { height: 30px; padding: 0 8px; font-size: 12px; white-space: nowrap; } + +.hidden { display: none !important; } +.notice { + min-height: 20px; margin-top: 12px; font-size: 14px; color: var(--danger); + text-align: center; white-space: pre-wrap; +} +.hint { color: var(--muted); font-size: 13px; line-height: 1.6; } +.status-row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; } +.chip { + display: inline-flex; align-items: center; min-height: 28px; padding: 3px 10px; + border-radius: 999px; background: #eef2f7; color: #334155; font-size: 13px; white-space: nowrap; +} +.chip.active { background: #dcfce7; color: #166534; } +.chip.locked { background: #ffedd5; color: #9a3412; } +.chip.leader { background: #dbeafe; color: #1e40af; } +.chip.member { background: #f1f5f9; color: #475569; } +.chip.full { background: #fee2e2; color: #991b1b; } +.section-head { display: flex; justify-content: space-between; align-items: center; gap: 14px; margin-bottom: 14px; flex-wrap: wrap; } +h1,h2,h3 { line-height: 1.25; } +h2 { margin: 0; font-size: 20px; } +h3 { margin: 0; font-size: 17px; } +.class-tabs { display: flex; gap: 8px; flex-wrap: wrap; } +.class-tab { + height: 34px; padding: 0 13px; border-radius: 8px; background: #fff; + border: 1px solid var(--border); color: #334155; +} +.class-tab.active { border-color: transparent; background: var(--primary); color: #fff; } + +.grid { + display: grid; grid-template-columns: repeat(auto-fill,minmax(290px,1fr)); gap: 16px; +} +.group-card { + background: var(--panel); border: 1px solid var(--border); border-radius: 14px; + padding: 15px; box-shadow: 0 5px 16px rgba(15,23,42,.05); align-self: start; +} +.group-card.own { border-color: var(--primary); box-shadow: 0 8px 24px rgba(37,99,235,.14); } +.group-top { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin-bottom: 12px; } +.group-title { font-weight: 750; overflow-wrap: anywhere; } +.member-list { margin: 0 0 12px; padding: 0; list-style: none; } +.member-list li { + min-height: 42px; padding: 7px 0; border-top: 1px solid #edf2f7; + display: flex; align-items: center; justify-content: space-between; gap: 8px; +} +.member-list li:first-child { border-top: 0; } +.member-name { min-width: 0; overflow-wrap: anywhere; } +.member-actions { display: flex; gap: 5px; flex-shrink: 0; } +.empty { color: var(--muted); text-align: center; padding: 24px 0; } +.form-line { display: flex; gap: 8px; align-items: center; } +.form-line input, .form-line select { flex: 1; min-width: 0; } +.actions { display: flex; gap: 8px; flex-wrap: wrap; } +.statistics { display: grid; grid-template-columns: repeat(3,1fr); gap: 10px; margin-bottom: 16px; } +.statistic { background: #fff; border: 1px solid var(--border); border-radius: 12px; padding: 13px; } +.statistic strong { display: block; font-size: 24px; } +.statistic span { color: var(--muted); font-size: 13px; } +.two-col { display: grid; grid-template-columns: 340px 1fr; gap: 18px; align-items: start; } +.identity { display: grid; grid-template-columns: auto 1fr; gap: 6px 14px; margin: 14px 0 18px; } +.identity span:first-child { color: var(--muted); } + +.modal-mask { + position: fixed; inset: 0; z-index: 100; display: grid; place-items: center; + background: rgba(15,23,42,.55); padding: 18px; backdrop-filter: blur(3px); +} +.modal { + width: min(460px,100%); background: #fff; border-radius: 15px; padding: 26px; box-shadow: var(--shadow); +} +.modal-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 20px; } +.toast { + position: fixed; left: 50%; bottom: 30px; transform: translateX(-50%) translateY(20px); + max-width: min(640px, calc(100vw - 32px)); padding: 13px 18px; border-radius: 10px; + background: #0f172a; color: #fff; box-shadow: var(--shadow); z-index: 200; opacity: 0; + transition: .2s; pointer-events: none; font-size: 14px; line-height: 1.5; +} +.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); } +.toast.error { background: #b91c1c; } +.loading { color: var(--muted); text-align: center; padding: 40px; } + +@media (max-width: 800px) { + .page { margin-top: 14px; } + .two-col { grid-template-columns: 1fr; } + .statistics { grid-template-columns: 1fr; } + .topbar { padding: 12px 14px; } + .brand span { display: none; } + .group-card { padding: 13px; } + .member-actions button { padding: 0 6px; } +} diff --git a/static/teacher.html b/static/teacher.html new file mode 100644 index 0000000..ff2ba05 --- /dev/null +++ b/static/teacher.html @@ -0,0 +1,75 @@ + + + + + + 分组管理后台 + + + +
+
师分组管理后台
+ +
+ +
+ + + +
+ + + + + diff --git a/static/teacher.js b/static/teacher.js new file mode 100644 index 0000000..58494bb --- /dev/null +++ b/static/teacher.js @@ -0,0 +1,225 @@ +const state = { classes: [], snapshot: null, activeClass: '', username: '' }; +const el = id => document.getElementById(id); + +function renderUnauthenticated() { + el('loginArea').classList.remove('hidden'); + el('mainArea').classList.add('hidden'); + el('teacherToolbar').classList.add('hidden'); +} + +function renderState() { + el('loginArea').classList.add('hidden'); + el('mainArea').classList.remove('hidden'); + el('teacherToolbar').classList.remove('hidden'); + el('teacherChip').textContent = `教师:${state.username}`; + renderClassTabs(); + renderStatistics(); + renderGroups(); + renderUngrouped(); +} + +function renderClassTabs() { + el('classTabs').innerHTML = state.classes.map(item => ` + + `).join(''); + const current = state.classes.find(item => item.class_name === state.activeClass); + const locked = current ? current.locked : false; + el('managementStatus').innerHTML = [ + `开放登录:${text(state.activeClass)}班`, + `${locked ? '已停止学生操作' : '学生可正常操作'}`, + `${state.snapshot.statistics.group_count} 个小组` + ].join(''); + el('lockButton').textContent = locked ? '恢复学生操作' : '停止学生操作'; + el('lockButton').className = locked ? 'success' : 'warning'; + el('exportButton').disabled = false; + el('classTabs').querySelectorAll('button[data-class]').forEach(button => { + button.addEventListener('click', () => switchClass(button.dataset.class)); + }); +} + +function renderStatistics() { + const stats = state.snapshot.statistics; + el('statistics').innerHTML = [ + { label: '班级总人数', value: stats.total_students }, + { label: '已分组人数', value: stats.grouped_students }, + { label: '未分组人数', value: stats.total_students - stats.grouped_students } + ].map(item => `
${text(item.value)}${item.label}
`).join(''); +} + +function memberActions(member, group) { + const isLeader = member.id === group.leader_student_id; + return ` + + + + `; +} + +function renderGroups() { + const grid = el('groupsGrid'); + if (!state.snapshot.groups.length) { + grid.innerHTML = '
该班还没有小组,可先手工创建,或等学生自行创建。
'; + return; + } + grid.innerHTML = state.snapshot.groups.map(group => ` +
+
+
+ + +
+ ${group.member_count}/${state.snapshot.group_limit} 人 +
+
    + ${group.members.length ? group.members.map(member => `
  • ${text(member.student_no)} · ${text(member.name)} ${member.is_leader ? '组长' : ''}${memberActions(member, group)}
  • `).join('') : '
  • 暂无成员
  • '} +
+
+ +
+
+ `).join(''); + + grid.querySelectorAll('form[data-rename]').forEach(form => form.addEventListener('submit', async event => { + event.preventDefault(); + const result = await api('teacher/rename-group', { + method: 'POST', json: { group_id: Number(form.dataset.rename), name: form.querySelector('input').value } + }); + toast(result.message, !result.ok); + if (result.ok) await loadState(); + })); + bindGridActions(grid); +} + +function bindGridActions(root) { + root.querySelectorAll('button[data-action]').forEach(button => button.addEventListener('click', async () => { + const action = button.dataset.action; + if (action === 'disband' && !requireConfirm('解散后所有成员都会变为未分组,确定继续吗?')) return; + if (action === 'remove' && !requireConfirm('确定将该学生移出当前小组吗?')) return; + let result; + if (action === 'leader') { + const groupId = Number(button.dataset.group); + const studentId = button.textContent.trim() === '取消组长' ? null : Number(button.dataset.student); + result = await api('teacher/set-leader', { method: 'POST', json: { group_id: groupId, student_id: studentId } }); + } else if (action === 'remove') { + result = await api('teacher/remove-member', { method: 'POST', json: { student_id: Number(button.dataset.student) } }); + } else if (action === 'disband') { + result = await api('teacher/disband', { method: 'POST', json: { group_id: Number(button.dataset.group) } }); + } + toast(result.message, !result.ok); + if (result.ok) await loadState(); + })); +} + +function renderUngrouped() { + const container = el('ungroupedList'); + const students = state.snapshot.ungrouped; + if (!students.length) { + container.innerHTML = '
没有未分组学生。
'; + return; + } + const options = state.snapshot.groups.map(group => ``).join(''); + container.innerHTML = ` +
+ ${students.map(student => ` +
+
${text(student.student_no)} · ${text(student.name)}${text(student.class_name)}班
+
+ + +
+
`).join('')} +
`; + container.querySelectorAll('form[data-add]').forEach(form => form.addEventListener('submit', async event => { + event.preventDefault(); + const group = Number(form.querySelector('select').value); + if (!group) return; + const result = await api('teacher/add-member', { + method: 'POST', json: { group_id: group, student_id: Number(form.dataset.add) } + }); + toast(result.message, !result.ok); + if (result.ok) await loadState(); + })); +} + +async function loadState() { + const result = await api('state'); + if (!result.ok || !result.authenticated || result.role !== 'teacher') return renderUnauthenticated(); + state.classes = result.classes; + state.snapshot = result.snapshot; + state.activeClass = result.active_class; + state.username = result.teacher.username; + renderState(); +} + +async function submitLogin(event) { + event.preventDefault(); + setButtonsDisabled(el('loginForm'), true); + const result = await api('teacher-login', { + method: 'POST', json: { username: el('username').value.trim(), password: el('password').value } + }); + setButtonsDisabled(el('loginForm'), false); + el('loginNotice').textContent = result.ok ? '' : result.message; + if (result.ok) { + toast(result.message); + await loadState(); + } +} + +async function switchClass(className) { + const result = await api('teacher/class', { method: 'POST', json: { class_name: className } }); + toast(result.message, !result.ok); + if (result.ok) await loadState(); +} + +async function toggleLock() { + const current = state.classes.find(item => item.class_name === state.activeClass); + const locked = !current || !current.locked; + const message = locked ? '停止后学生只能查看分组结果,不能再创建/加入/退出。确定继续吗?' : '确定恢复学生分组操作吗?'; + if (!requireConfirm(message)) return; + const result = await api('teacher/lock', { method: 'POST', json: { class_name: state.activeClass, locked } }); + toast(result.message, !result.ok); + if (result.ok) await loadState(); +} + +async function submitCreateGroup(event) { + event.preventDefault(); + const result = await api('teacher/create-group', { + method: 'POST', json: { class_name: state.activeClass, name: el('newGroupName').value } + }); + toast(result.message, !result.ok); + if (result.ok) { + el('newGroupName').value = ''; + await loadState(); + } +} + +async function importRoster(file) { + if (!file) return; + if (!requireConfirm('导入名单会重建全部学生账号和登录密码,并解散所有班级的已有分组。确定继续吗?')) return; + toast('正在导入 Excel,请稍候...'); + const result = await api(`teacher/import?filename=${encodeURIComponent(file.name)}`, { + method: 'POST', headers: { 'Content-Type': 'application/octet-stream' }, body: file + }); + toast(result.message, !result.ok); + if (result.ok) await loadState(); +} + +async function logout() { + await api('logout', { method: 'POST' }); + renderUnauthenticated(); +} + +el('loginForm').addEventListener('submit', submitLogin); +el('logoutButton').addEventListener('click', logout); +el('lockButton').addEventListener('click', toggleLock); +el('createGroupForm').addEventListener('submit', submitCreateGroup); +el('exportButton').addEventListener('click', () => { + window.location.href = `/api/teacher/export?class=${encodeURIComponent(state.activeClass)}`; +}); +el('importFile').addEventListener('change', event => { + importRoster(event.target.files[0]); + event.target.value = ''; +}); + +loadState(); diff --git a/xlsxio.py b/xlsxio.py new file mode 100644 index 0000000..44dbd90 --- /dev/null +++ b/xlsxio.py @@ -0,0 +1,242 @@ +#!/usr/bin/env python3 +"""Minimal XLSX reader/writer using only the Python standard library.""" + +from __future__ import annotations + +import re +from datetime import datetime +from pathlib import Path +from xml.etree import ElementTree as ET +from zipfile import ZIP_DEFLATED, ZipFile + + +MAIN_NS = "http://schemas.openxmlformats.org/spreadsheetml/2006/main" +REL_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships" +PKG_REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships" +NS = {"m": MAIN_NS, "r": REL_NS, "p": PKG_REL_NS} + + +def _column_number(cell_ref: str) -> int: + letters = re.match(r"([A-Z]+)", cell_ref.upper()) + if not letters: + return 0 + value = 0 + for letter in letters.group(1): + value = value * 26 + ord(letter) - 64 + return value + + +def _column_letter(number: int) -> str: + result = "" + while number: + number, remainder = divmod(number - 1, 26) + result = chr(65 + remainder) + result + return result + + +def _shared_strings(archive: ZipFile) -> list[str]: + if "xl/sharedStrings.xml" not in archive.namelist(): + return [] + root = ET.fromstring(archive.read("xl/sharedStrings.xml")) + values = [] + for item in root.findall("m:si", NS): + values.append("".join(node.text or "" for node in item.iter(f"{{{MAIN_NS}}}t"))) + return values + + +def _relationships(archive: ZipFile) -> dict[str, str]: + root = ET.fromstring(archive.read("xl/_rels/workbook.xml.rels")) + return {item.attrib["Id"]: item.attrib["Target"] for item in root} + + +def _cell_value(cell, shared: list[str]) -> str: + cell_type = cell.attrib.get("t") + inline = cell.find("m:is", NS) + if cell_type == "inlineStr" and inline is not None: + return "".join(node.text or "" for node in inline.iter(f"{{{MAIN_NS}}}t")).strip() + value_node = cell.find("m:v", NS) + raw = "" if value_node is None else (value_node.text or "") + if cell_type == "s" and raw: + return shared[int(raw)].strip() + return raw.strip() + + +def read_xlsx_rows(path: str | Path) -> list[dict[str, str]]: + """Read the first worksheet as dictionaries keyed by header text.""" + path = Path(path) + with ZipFile(path, "r") as archive: + shared = _shared_strings(archive) + relationships = _relationships(archive) + workbook = ET.fromstring(archive.read("xl/workbook.xml")) + sheets = workbook.findall("m:sheets/m:sheet", NS) + if not sheets: + raise ValueError("Excel 文件中没有工作表") + relationship_id = sheets[0].attrib[f"{{{REL_NS}}}id"] + target = relationships[relationship_id].lstrip("/") + if not target.startswith("xl/"): + target = "xl/" + target + worksheet = ET.fromstring(archive.read(target)) + + rows: list[dict[int, str]] = [] + for row in worksheet.findall("m:sheetData/m:row", NS): + values: dict[int, str] = {} + for cell in row.findall("m:c", NS): + number = _column_number(cell.attrib.get("r", "A1")) + value = _cell_value(cell, shared) + if value != "": + values[number] = value + rows.append(values) + + header_row_index = -1 + for index, values in enumerate(rows[:20]): + texts = set(values.values()) + if "学号" in texts and "姓名" in texts: + header_row_index = index + break + if header_row_index < 0: + raise ValueError("Excel 中必须包含“学号”和“姓名”表头") + + headers = rows[header_row_index] + result: list[dict[str, str]] = [] + for values in rows[header_row_index + 1:]: + row = {headers.get(number, f"列{number}"): value for number, value in values.items()} + if any(str(value).strip() for value in row.values()): + result.append(row) + return result + + +def _xml_escape(value: object) -> str: + return ( + str(value) + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace('"', """) + ) + + +def _sheet_xml(headers: list[str], records: list[dict[str, object]]) -> str: + all_headers = list(headers) + all_records = list(records) + row_xml: list[str] = [] + header_cells = [] + for column_number, header in enumerate(all_headers, 1): + reference = f"{_column_letter(column_number)}1" + header_cells.append( + f'{_xml_escape(header)}' + ) + row_xml.append(f'{"".join(header_cells)}') + + for row_number, record in enumerate(all_records, 2): + cells = [] + for column_number, header in enumerate(all_headers, 1): + value = record.get(header, "") + reference = f"{_column_letter(column_number)}{row_number}" + if isinstance(value, bool): + cells.append(f'{_xml_escape("是" if value else "否")}') + elif isinstance(value, (int, float)) and not isinstance(value, bool): + cells.append(f'{value}') + else: + text = "" if value is None else str(value) + preserve = ' xml:space="preserve"' if text != text.strip() else "" + cells.append( + f'{_xml_escape(text)}' + ) + row_xml.append(f'{"".join(cells)}') + + column_xml = [] + for number in range(1, len(all_headers) + 1): + column_xml.append(f'') + + return f''' + + +{"".join(column_xml)} +{"".join(row_xml)} +''' + + +def write_xlsx(sheets: list[tuple[str, list[str], list[dict[str, object]]]]) -> bytes: + """Create a valid XLSX in memory. Each item is (sheet name, headers, records).""" + import posixpath + + used_names = set() + safe_names = [] + for index, (name, _, _) in enumerate(sheets, 1): + clean = re.sub(r"[\\/?*\\[\\]:]", "-", name).strip() or f"Sheet{index}" + clean = clean[:31] + candidate = clean + suffix = 2 + while candidate.lower() in used_names: + tail = f"-{suffix}" + candidate = clean[: 31 - len(tail)] + tail + suffix += 1 + used_names.add(candidate.lower()) + safe_names.append(candidate) + + sheet_entries = [] + workbook_entries = [] + worksheet_files = [] + for index, name in enumerate(safe_names, 1): + filename = f"xl/worksheets/sheet{index}.xml" + worksheet_files.append((filename, _sheet_xml(sheets[index - 1][1], sheets[index - 1][2]))) + sheet_entries.append( + f'' + ) + workbook_entries.append( + f'' + ) + style_relationship_id = len(sheets) + 1 + + content_overrides = "".join( + f'' + for index in range(1, len(sheets) + 1) + ) + + files = { + "[Content_Types].xml": f''' + + + + +{content_overrides} +''', + "_rels/.rels": ''' + + +''', + "xl/workbook.xml": f''' + +{"".join(sheet_entries)} +''', + "xl/_rels/workbook.xml.rels": f''' +{"".join(workbook_entries)} + +''', + "xl/styles.xml": ''' + + + + + + +''', + } + files.update(worksheet_files) + + import io + output = io.BytesIO() + with ZipFile(output, "w", ZIP_DEFLATED) as archive: + for filename, content in files.items(): + archive.writestr(filename, content.encode("utf-8")) + return output.getvalue() + + +def export_filename(prefix: str, class_name: str | None = None) -> str: + timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") + suffix = f"-{class_name}班" if class_name else "" + return f"{prefix}{suffix}-{timestamp}.xlsx"