Files
selfgroup/static/teacher.js

331 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();