331 lines
14 KiB
JavaScript
331 lines
14 KiB
JavaScript
const state = { classes: [], snapshot: null, activeClass: '', username: '' };
|
||
const el = id => document.getElementById(id);
|
||
const adminTabs = ['groups', 'ungrouped', 'accounts', 'settings'];
|
||
|
||
function renderUnauthenticated() {
|
||
el('loginArea').classList.remove('hidden');
|
||
el('mainArea').classList.add('hidden');
|
||
el('teacherToolbar').classList.add('hidden');
|
||
}
|
||
|
||
function switchAdminTab(tabName) {
|
||
if (!adminTabs.includes(tabName)) tabName = 'groups';
|
||
document.querySelectorAll('.admin-tab').forEach(button => {
|
||
const active = button.dataset.tab === tabName;
|
||
button.classList.toggle('active', active);
|
||
button.setAttribute('aria-selected', String(active));
|
||
el(`${button.dataset.tab}Panel`).classList.toggle('hidden', !active);
|
||
});
|
||
}
|
||
|
||
function renderState() {
|
||
el('loginArea').classList.add('hidden');
|
||
el('mainArea').classList.remove('hidden');
|
||
el('teacherToolbar').classList.remove('hidden');
|
||
el('teacherChip').textContent = `教师:${state.username}`;
|
||
el('groupLimit').value = state.snapshot.group_limit;
|
||
renderClassTabs();
|
||
renderStatistics();
|
||
renderGroups();
|
||
renderUngrouped();
|
||
renderStudentAccounts();
|
||
}
|
||
|
||
function allStudents() {
|
||
return [
|
||
...state.snapshot.groups.flatMap(group => group.members),
|
||
...state.snapshot.ungrouped
|
||
].sort((left, right) => String(left.student_no).localeCompare(String(right.student_no), 'zh-Hans-CN'));
|
||
}
|
||
|
||
function renderStudentAccounts() {
|
||
const keyword = el('studentNoSearch').value.trim().toLocaleLowerCase();
|
||
const students = allStudents().filter(student =>
|
||
String(student.student_no).toLocaleLowerCase().includes(keyword)
|
||
);
|
||
const select = el('studentSelect');
|
||
const previous = select.value;
|
||
select.innerHTML = students.length ? students.map(student => `
|
||
<option value="${student.id}">${text(student.student_no)} · ${text(student.name)}</option>
|
||
`).join('') : '<option value="">未找到匹配的学号</option>';
|
||
if (students.some(student => String(student.id) === previous)) {
|
||
select.value = previous;
|
||
}
|
||
}
|
||
|
||
async function submitStudentPassword(event) {
|
||
event.preventDefault();
|
||
const notice = el('studentPasswordNotice');
|
||
const select = el('studentSelect');
|
||
const student = allStudents().find(item => String(item.id) === select.value);
|
||
const newPassword = el('studentNewPassword').value;
|
||
const confirmPassword = el('studentConfirmPassword').value;
|
||
notice.textContent = '';
|
||
if (!student) {
|
||
notice.textContent = '请选择要修改密码的学生';
|
||
return;
|
||
}
|
||
if (newPassword !== confirmPassword) {
|
||
notice.textContent = '两次输入的新密码不一致';
|
||
return;
|
||
}
|
||
if (!requireConfirm(`确定修改 ${student.student_no} ${student.name} 的登录密码吗?修改后该学生需要重新登录。`)) return;
|
||
setButtonsDisabled(el('studentPasswordForm'), true);
|
||
const result = await api('teacher/student-password', {
|
||
method: 'POST', json: { student_id: Number(select.value), new_password: newPassword }
|
||
});
|
||
setButtonsDisabled(el('studentPasswordForm'), false);
|
||
notice.textContent = result.ok ? '' : result.message;
|
||
toast(result.message, !result.ok);
|
||
if (result.ok) el('studentPasswordForm').reset();
|
||
}
|
||
|
||
document.querySelectorAll('.admin-tab').forEach(button => {
|
||
button.addEventListener('click', () => switchAdminTab(button.dataset.tab));
|
||
});
|
||
|
||
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 submitTeacherPassword(event) {
|
||
event.preventDefault();
|
||
const oldPassword = el('teacherOldPassword').value;
|
||
const newPassword = el('teacherNewPassword').value;
|
||
const confirmPassword = el('teacherConfirmPassword').value;
|
||
const notice = el('teacherPasswordNotice');
|
||
notice.textContent = '';
|
||
if (newPassword !== confirmPassword) {
|
||
notice.textContent = '两次输入的新密码不一致';
|
||
return;
|
||
}
|
||
setButtonsDisabled(el('teacherPasswordForm'), true);
|
||
const result = await api('teacher/change-password', {
|
||
method: 'POST', json: { old_password: oldPassword, new_password: newPassword }
|
||
});
|
||
setButtonsDisabled(el('teacherPasswordForm'), false);
|
||
notice.textContent = result.ok ? '' : result.message;
|
||
toast(result.message, !result.ok);
|
||
if (result.ok) {
|
||
el('teacherPasswordForm').reset();
|
||
}
|
||
}
|
||
|
||
async function submitGroupLimit(event) {
|
||
event.preventDefault();
|
||
setButtonsDisabled(el('groupLimitForm'), true);
|
||
const result = await api('teacher/group-limit', {
|
||
method: 'POST', json: { limit: Number(el('groupLimit').value) }
|
||
});
|
||
setButtonsDisabled(el('groupLimitForm'), false);
|
||
el('groupLimitNotice').textContent = result.ok ? '' : result.message;
|
||
toast(result.message, !result.ok);
|
||
if (result.ok) 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('teacherPasswordForm').addEventListener('submit', submitTeacherPassword);
|
||
el('studentPasswordForm').addEventListener('submit', submitStudentPassword);
|
||
el('studentNoSearch').addEventListener('input', renderStudentAccounts);
|
||
el('groupLimitForm').addEventListener('submit', submitGroupLimit);
|
||
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();
|