first commit

This commit is contained in:
2026-09-08 10:06:45 +08:00
commit c6e9a82fe5
12 changed files with 2289 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
__pycache__/
*.pyc
selfgroup.db
*.db
.upload-roster.xlsx
.DS_Store

BIN
2025mt.xlsx Normal file

Binary file not shown.

90
README.md Normal file
View File

@@ -0,0 +1,90 @@
# 学生自助分组系统
这是一个基于 B/S 架构的本地/校园网分组系统。项目使用 Python 标准库实现,不需要安装第三方依赖;数据库为 SQLite,学生名单来源为 `2025mt.xlsx`。
## 功能
- 学生用学号登录,首次登录密码也是学号,并强制修改密码。
- 学生登录后显示姓名、学号和登录状态。
- 学生可创建组并自动成为组长;其他学生可选择加入。
- 每组最多 6 人,包括组长。
- 已加入其他组的学生不能创建新组;组长加入新组时,需确认解散原组;普通成员切换组时需确认退出原组。
- 学生端和教师端都能直接看到全部组及成员的学号、姓名,无需点开详情。
- 教师可切换“当前开放登录班级”,未开放班级不能登录。
- 教师可停止/恢复学生分组操作。
- 停止后学生只能查看;教师仍可手工建组、改名、设组长、加入、移出、解散。
- 教师可导出当前班分组为 `.xlsx`。
## 启动
在本目录执行:
```bash
python3 app.py
```
默认访问地址:
- 学生端:<http://127.0.0.1:8000/>
- 教师端:<http://127.0.0.1:8000/teacher>
如果学生要从其他电脑/手机访问,请把 `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 适合班级规模的小型并发使用,不建议多名教师同时频繁写数据库。

1136
app.py Normal file

File diff suppressed because it is too large Load Diff

4
start.sh Executable file
View File

@@ -0,0 +1,4 @@
#!/bin/sh
set -eu
cd "$(dirname "$0")"
exec python3 app.py

91
static/index.html Normal file
View File

@@ -0,0 +1,91 @@
<!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="userToolbar">
<span class="chip" id="userChip"></span>
<button class="ghost" id="logoutButton" type="button">退出</button>
</div>
</header>
<main class="page">
<div id="loginArea" class="login-wrap">
<form class="login-card" id="loginForm">
<h1 class="title">学生登录</h1>
<p class="subtitle">账号是你的学号,首次登录密码也是学号。登录后必须修改密码。</p>
<label for="username">学号</label>
<input id="username" name="username" inputmode="numeric" autocomplete="username" required>
<label for="password">密码</label>
<input id="password" name="password" type="password" autocomplete="current-password" required>
<button class="full" data-working type="submit">登录</button>
<div class="notice" id="loginNotice"></div>
<p class="hint">老师会发布学生端网址。只有当前开放班级的学生可以登录。</p>
</form>
</div>
<div id="mainArea" class="hidden">
<div class="statistics" id="statistics"></div>
<div class="two-col">
<div>
<section class="panel">
<div class="section-head"><h2>我的信息</h2><span class="chip" id="loginState">已登录</span></div>
<div class="identity" id="identity"></div>
<div id="myGroupAction"></div>
</section>
<section class="panel">
<div class="section-head"><h2>修改密码</h2></div>
<form id="passwordForm">
<label for="oldPassword">原密码</label>
<input id="oldPassword" type="password" autocomplete="current-password" required>
<label for="newPassword">新密码</label>
<input id="newPassword" type="password" minlength="8" autocomplete="new-password" required>
<label for="confirmPassword">确认新密码</label>
<input id="confirmPassword" type="password" minlength="8" autocomplete="new-password" required>
<button class="full" data-working type="submit">保存新密码</button>
<p class="hint">新密码至少 8 位,不能与学号或原密码相同。</p>
</form>
</section>
</div>
<section class="panel">
<div class="section-head">
<h2>全部小组</h2>
<div class="status-row" id="classStatus"></div>
</div>
<div class="grid" id="groupsGrid"></div>
</section>
</div>
</div>
</main>
<div class="modal-mask hidden" id="passwordModal">
<form class="modal" id="firstPasswordForm">
<h2 class="title">首次登录请修改密码</h2>
<p class="subtitle">为了防止他人用学号冒充你登录,必须先设置新的登录密码。</p>
<label for="firstOld">原密码(首次登录是你的学号)</label>
<input id="firstOld" type="password" required>
<label for="firstNew">新密码(至少 8 位)</label>
<input id="firstNew" type="password" minlength="8" required>
<label for="firstConfirm">确认新密码</label>
<input id="firstConfirm" type="password" minlength="8" required>
<div class="modal-actions">
<button class="success" data-working type="submit">确认修改</button>
<button class="ghost" type="button" id="logoutFromModal">退出登录</button>
</div>
<div class="notice" id="firstPasswordNotice"></div>
</form>
</div>
<script src="/shared.js"></script>
<script src="/student.js"></script>
</body>
</html>

53
static/shared.js Normal file
View File

@@ -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 => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[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';
});
}

211
static/student.js Normal file
View File

@@ -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 => `<div class="statistic"><strong>${text(item.value)}</strong><span>${item.label}</span></div>`).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 => `<span>${text(row[0])}</span><strong>${text(row[1])}</strong>`).join('');
el('classStatus').innerHTML = [
`<span class="chip">${text(user.class_name)}班</span>`,
`<span class="chip ${state.snapshot.locked ? 'locked' : 'active'}">${state.snapshot.locked ? '老师已停止操作' : '分组进行中'}</span>`
].join('');
}
function renderMyGroup() {
const target = el('myGroupAction');
const membership = state.membership;
if (!membership) {
target.innerHTML = `
<form id="createForm">
<label for="groupName">创建新组</label>
<div class="form-line">
<input id="groupName" maxlength="30" placeholder="输入组名,例如:第 1 组" required>
<button data-working type="submit">创建</button>
</div>
<p class="hint">创建后你是组长。每组成员(包括组长)最多 ${state.snapshot.group_limit} 人。已加入别人的组时不能创建。</p>
</form>`;
el('createForm').addEventListener('submit', submitCreate);
return;
}
const isLeader = membership.leader_student_id === state.user.id;
target.innerHTML = `
<div class="status-row">
<span class="chip">当前组:${text(membership.name)}</span>
<span class="chip ${isLeader ? 'leader' : 'member'}">${isLeader ? '我是组长' : '我是组员'}</span>
</div>
<div class="actions" style="margin-top:14px">
<button class="${isLeader ? 'danger' : 'ghost'}" data-working id="leaveButton" type="button">${isLeader ? '解散我的组' : '退出当前组'}</button>
</div>
<p class="hint">${isLeader
? '组长退出会解散整个组,所有成员都会变为未分组。'
: '退出后可以加入其他组。已加入其他组时,不能创建新组。'}</p>`;
el('leaveButton').addEventListener('click', submitLeave);
}
function memberLine(member) {
return `<span class="member-name">${text(member.student_no)} · ${text(member.name)}${member.is_leader ? ' <span class="chip leader">组长</span>' : ''}</span>`;
}
function renderGroups() {
const grid = el('groupsGrid');
if (!state.snapshot.groups.length) {
grid.innerHTML = '<div class="empty">当前班级还没有小组。你可以创建第一个组。</div>';
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 `
<article class="group-card ${own ? 'own' : ''}">
<div class="group-top">
<div class="group-title">${text(group.name)}</div>
<span class="chip ${full ? 'full' : ''}">${group.member_count}/${state.snapshot.group_limit} 人</span>
</div>
<ul class="member-list">
${group.members.length ? group.members.map(member => `<li>${memberLine(member)}</li>`).join('') : '<li><span class="hint">暂无成员</span></li>'}
</ul>
${own ? '<span class="chip active">我的当前组</span>'
: `<button class="small" data-working data-group="${group.id}" data-reason="${text(disabledReason)}">加入</button>`}
</article>`;
}).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();

156
static/style.css Normal file
View File

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

75
static/teacher.html Normal file
View File

@@ -0,0 +1,75 @@
<!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="teacherToolbar">
<span class="chip" id="teacherChip"></span>
<button class="ghost" id="logoutButton" type="button">退出</button>
</div>
</header>
<main class="page">
<div id="loginArea" class="login-wrap">
<form class="login-card" id="loginForm">
<h1 class="title">教师登录</h1>
<p class="subtitle">请使用教师后台账号登录,管理开放班级和分组。</p>
<label for="username">教师账号</label>
<input id="username" autocomplete="username" required>
<label for="password">密码</label>
<input id="password" type="password" autocomplete="current-password" required>
<button class="full" data-working type="submit">登录后台</button>
<div class="notice" id="loginNotice"></div>
<p class="hint">默认账号 teacher,默认密码 teacher123。正式使用前请通过环境变量修改。</p>
</form>
</div>
<div id="mainArea" class="hidden">
<section class="panel">
<div class="section-head">
<h2>班级管理</h2>
<div class="actions">
<button class="success" id="exportButton" type="button">导出当前班 Excel</button>
<label class="ghost button small" style="display:inline-flex;align-items:center;padding:0 10px;background:#eef2f7;color:#18212f;border-radius:8px;height:38px">
上传名单<input id="importFile" type="file" accept=".xlsx" class="hidden">
</label>
<button class="warning" id="lockButton" type="button">停止学生操作</button>
</div>
</div>
<div class="class-tabs" id="classTabs"></div>
<div class="status-row" style="margin-top:14px" id="managementStatus"></div>
<p class="hint">点击班级即切换当前开放登录班级,并查看/管理该班分组。其它班学生不能登录。</p>
</section>
<div class="statistics" id="statistics"></div>
<section class="panel">
<div class="section-head"><h2>手工新建小组</h2></div>
<form class="form-line" id="createGroupForm">
<input id="newGroupName" maxlength="30" placeholder="输入新组名,例如:第 9 组" required>
<button data-working type="submit">创建小组</button>
</form>
</section>
<section class="panel">
<div class="section-head"><h2>全部小组(成员直接可见)</h2></div>
<div class="grid" id="groupsGrid"></div>
</section>
<section class="panel">
<div class="section-head"><h2>未分组学生</h2></div>
<div id="ungroupedList"></div>
</section>
</div>
</main>
<script src="/shared.js"></script>
<script src="/teacher.js"></script>
</body>
</html>

225
static/teacher.js Normal file
View File

@@ -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 => `
<button class="class-tab ${item.class_name === state.activeClass ? 'active' : ''}"
data-class="${text(item.class_name)}" type="button">${text(item.class_name)}班(${item.student_count})</button>
`).join('');
const current = state.classes.find(item => item.class_name === state.activeClass);
const locked = current ? current.locked : false;
el('managementStatus').innerHTML = [
`<span class="chip active">开放登录:${text(state.activeClass)}班</span>`,
`<span class="chip ${locked ? 'locked' : ''}">${locked ? '已停止学生操作' : '学生可正常操作'}</span>`,
`<span class="chip">${state.snapshot.statistics.group_count} 个小组</span>`
].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 => `<div class="statistic"><strong>${text(item.value)}</strong><span>${item.label}</span></div>`).join('');
}
function memberActions(member, group) {
const isLeader = member.id === group.leader_student_id;
return `
<span class="member-actions">
<button class="small ghost" data-action="leader" data-group="${group.id}" data-student="${member.id}">${isLeader ? '取消组长' : '设组长'}</button>
<button class="small danger" data-action="remove" data-student="${member.id}">移出</button>
</span>`;
}
function renderGroups() {
const grid = el('groupsGrid');
if (!state.snapshot.groups.length) {
grid.innerHTML = '<div class="empty">该班还没有小组,可先手工创建,或等学生自行创建。</div>';
return;
}
grid.innerHTML = state.snapshot.groups.map(group => `
<article class="group-card">
<div class="group-top">
<form class="form-line" data-rename="${group.id}">
<input value="${text(group.name)}" maxlength="30" aria-label="组名">
<button class="small ghost" type="submit">保存</button>
</form>
<span class="chip ${group.full ? 'full' : ''}">${group.member_count}/${state.snapshot.group_limit} 人</span>
</div>
<ul class="member-list">
${group.members.length ? group.members.map(member => `<li>${text(member.student_no)} · ${text(member.name)} ${member.is_leader ? '<span class="chip leader">组长</span>' : ''}${memberActions(member, group)}</li>`).join('') : '<li><span class="hint">暂无成员</span></li>'}
</ul>
<div class="actions">
<button class="small danger" data-action="disband" data-group="${group.id}">解散小组</button>
</div>
</article>
`).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 = '<div class="empty">没有未分组学生。</div>';
return;
}
const options = state.snapshot.groups.map(group => `<option value="${group.id}">${text(group.name)}(${group.member_count}/${state.snapshot.group_limit})</option>`).join('');
container.innerHTML = `
<div class="grid">
${students.map(student => `
<div class="group-card">
<div class="group-top"><strong>${text(student.student_no)} · ${text(student.name)}</strong><span class="chip">${text(student.class_name)}班</span></div>
<form class="form-line" data-add="${student.id}">
<select required><option value="">选择目标小组</option>${options}</select>
<button class="small success" type="submit">加入</button>
</form>
</div>`).join('')}
</div>`;
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();

242
xlsxio.py Normal file
View File

@@ -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("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;")
)
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'<c r="{reference}" t="inlineStr"><is><t>{_xml_escape(header)}</t></is></c>'
)
row_xml.append(f'<row r="1">{"".join(header_cells)}</row>')
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'<c r="{reference}" t="inlineStr"><is><t>{_xml_escape("是" if value else "否")}</t></is></c>')
elif isinstance(value, (int, float)) and not isinstance(value, bool):
cells.append(f'<c r="{reference}"><v>{value}</v></c>')
else:
text = "" if value is None else str(value)
preserve = ' xml:space="preserve"' if text != text.strip() else ""
cells.append(
f'<c r="{reference}" t="inlineStr"><is><t{preserve}>{_xml_escape(text)}</t></is></c>'
)
row_xml.append(f'<row r="{row_number}">{"".join(cells)}</row>')
column_xml = []
for number in range(1, len(all_headers) + 1):
column_xml.append(f'<col min="{number}" max="{number}" width="18" customWidth="1"/>')
return f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<worksheet xmlns="{MAIN_NS}">
<sheetViews><sheetView workbookViewId="0"/></sheetViews>
<cols>{"".join(column_xml)}</cols>
<sheetData>{"".join(row_xml)}</sheetData>
</worksheet>'''
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'<sheet name="{_xml_escape(name)}" sheetId="{index}" '
f'r:id="rId{index}"/>'
)
workbook_entries.append(
f'<Relationship Id="rId{index}" '
f'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" '
f'Target="worksheets/sheet{index}.xml"/>'
)
style_relationship_id = len(sheets) + 1
content_overrides = "".join(
f'<Override PartName="/xl/worksheets/sheet{index}.xml" '
'ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>'
for index in range(1, len(sheets) + 1)
)
files = {
"[Content_Types].xml": f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>{content_overrides}
</Types>''',
"_rels/.rels": '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
</Relationships>''',
"xl/workbook.xml": f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<workbook xmlns="{MAIN_NS}" xmlns:r="{REL_NS}">
<sheets>{"".join(sheet_entries)}</sheets>
</workbook>''',
"xl/_rels/workbook.xml.rels": f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">{"".join(workbook_entries)}
<Relationship Id="rId{style_relationship_id}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
</Relationships>''',
"xl/styles.xml": '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<fonts count="2"><font><sz val="11"/><name val="Calibri"/></font><font><b/><sz val="11"/><name val="Calibri"/></font></fonts>
<fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills>
<borders count="1"><border/></borders>
<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>
<cellXfs count="2"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/><xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0" applyFont="1"/></cellXfs>
</styleSheet>''',
}
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"