first commit
This commit is contained in:
91
static/index.html
Normal file
91
static/index.html
Normal 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
53
static/shared.js
Normal 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 => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||||
}[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
211
static/student.js
Normal 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
156
static/style.css
Normal 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
75
static/teacher.html
Normal 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
225
static/teacher.js
Normal 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();
|
||||
Reference in New Issue
Block a user