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

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();