first commit
This commit is contained in:
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