Files
SMANAGE/TempLate/rollcall/admin/index.html
T
TermiNexus f8d356f3ff chore(baseline): 注册 RollCall/Viper/Venom 子域与站点,补 rollcall 应用
Main.py:WHITE_HOSTS 增 rollcall/viper/venom 主机,STATIC_SITES 增 rollcall/venom 静态根,静态站点路由下 /API/ 前缀请求转交 Django 处理。

gvsds:settings.py 注册 gvsds.apps.rollcall,urls.py 挂载 API/RollCall/;新增 rollcall 应用(模型/视图/路由/迁移 + RollCallManager 数据访问层)与 TempLate/rollcall 前端站点。

.gitignore:排除 Viper/Venom 前端工程与部署产物、TempLate/FTP 二进制分发目录。
2026-09-14 12:30:06 +08:00

790 lines
34 KiB
HTML
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.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>同学点名 · 管理</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;700;900&family=Noto+Serif+SC:wght@600;700;900&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="../style.css" />
</head>
<body>
<!-- 顶部导航 -->
<header class="topbar">
<div class="brand">
<span class="brand-mark" aria-hidden="true">
<svg viewBox="0 0 32 32" width="28" height="28">
<rect x="3" y="6" width="26" height="20" rx="3" fill="#e8a838"/>
<rect x="3" y="6" width="26" height="6" rx="3" fill="#1a2238"/>
<circle cx="11" cy="19" r="3" fill="#f5f0e6"/>
<rect x="16" y="17" width="9" height="2" rx="1" fill="#1a2238"/>
<rect x="16" y="21" width="7" height="2" rx="1" fill="#1a2238"/>
</svg>
</span>
<h1 class="brand-title">同学点名</h1>
<span class="brand-sub">管理控制台</span>
</div>
<nav class="tabs" role="tablist">
<button class="tab active" data-view="manage" role="tab">学生管理</button>
<button class="tab" data-view="question" role="tab">题目管理</button>
</nav>
<div class="topbar-right">
<a class="count-badge" href="../" style="text-decoration:none;" title="返回点名主页">返回点名主页</a>
</div>
</header>
<main class="app">
<!-- ============ 学生管理视图 ============ -->
<section class="view view-manage active" id="view-manage">
<div class="manage-head">
<div>
<h2 class="section-title">学生管理</h2>
<p class="section-desc">添加、编辑学生信息,支持导入导出 JSON 数据</p>
</div>
<div class="manage-actions">
<button class="btn btn-primary" id="addStudentBtn">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
添加学生
</button>
<button class="btn btn-ghost" id="exportBtn">导出 JSON</button>
<button class="btn btn-ghost" id="importBtn">导入 JSON</button>
<input type="file" id="importInput" accept=".json" hidden />
</div>
</div>
<div class="search-bar">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input type="text" id="searchInput" placeholder="搜索姓名或学号…" />
</div>
<div class="table-wrap">
<table class="student-table">
<thead>
<tr>
<th style="width:56px">头像</th>
<th>姓名</th>
<th>学号</th>
<th>分组</th>
<th>备注</th>
<th style="width:140px">操作</th>
</tr>
</thead>
<tbody id="studentTbody"></tbody>
</table>
<div class="empty-state" id="manageEmpty" hidden>
<p>暂无学生数据,点击"添加学生"或"导入 JSON"开始</p>
</div>
</div>
</section>
<!-- ============ 题目管理视图 ============ -->
<section class="view view-question" id="view-question">
<div class="manage-head">
<div>
<h2 class="section-title">题目管理</h2>
<p class="section-desc">按学科管理题库,支持随机抽取题目用于课堂提问</p>
</div>
<div class="manage-actions">
<button class="btn btn-primary" id="addQuestionBtn">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
添加题目
</button>
<button class="btn btn-ghost" id="exportQBtn">导出题库</button>
<button class="btn btn-ghost" id="importQBtn">导入题库</button>
<input type="file" id="importQInput" accept=".json" hidden />
</div>
</div>
<div class="search-bar">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input type="text" id="qSearchInput" placeholder="搜索题目内容…" />
<select class="subject-filter" id="qSubjectFilter">
<option value="">全部学科</option>
</select>
</div>
<div class="table-wrap">
<table class="student-table">
<thead>
<tr>
<th style="width:90px">学科</th>
<th>题目内容</th>
<th style="width:80px">难度</th>
<th style="width:140px">操作</th>
</tr>
</thead>
<tbody id="questionTbody"></tbody>
</table>
<div class="empty-state" id="questionEmpty" hidden>
<p>暂无题目数据,点击"添加题目"开始</p>
</div>
</div>
</section>
</main>
<!-- 添加/编辑学生弹窗 -->
<div class="modal-mask" id="studentModal" hidden>
<div class="modal">
<div class="modal-head">
<h3 id="modalTitle">添加学生</h3>
<button class="modal-close" id="modalClose" aria-label="关闭">×</button>
</div>
<div class="modal-body">
<div class="avatar-picker">
<div class="avatar-preview" id="avatarPreview"></div>
<div class="avatar-options" id="avatarOptions"></div>
</div>
<div class="form-row">
<label>姓名 <span class="req">*</span></label>
<input type="text" id="formName" placeholder="请输入学生姓名" maxlength="20" />
</div>
<div class="form-row">
<label>学号</label>
<input type="text" id="formNo" placeholder="可选" maxlength="20" />
</div>
<div class="form-row">
<label>分组</label>
<input type="text" id="formGroup" placeholder="可选,如:第一组 / A组" list="groupList" maxlength="20" />
<datalist id="groupList"></datalist>
</div>
<div class="form-row">
<label>备注</label>
<input type="text" id="formNote" placeholder="可选,如班级、职务" maxlength="40" />
</div>
</div>
<div class="modal-foot">
<button class="btn btn-ghost" id="modalCancel">取消</button>
<button class="btn btn-primary" id="modalSave">保存</button>
</div>
</div>
</div>
<!-- 添加/编辑题目弹窗 -->
<div class="modal-mask" id="questionModal" hidden>
<div class="modal">
<div class="modal-head">
<h3 id="qModalTitle">添加题目</h3>
<button class="modal-close" id="qModalClose" aria-label="关闭">×</button>
</div>
<div class="modal-body">
<div class="form-row">
<label>学科 <span class="req">*</span></label>
<input type="text" id="qFormSubject" placeholder="如:语文、数学、英语" list="subjectList" maxlength="20" />
<datalist id="subjectList"></datalist>
</div>
<div class="form-row">
<label>题目内容 <span class="req">*</span></label>
<textarea id="qFormContent" placeholder="请输入题目内容" rows="4" maxlength="500"></textarea>
</div>
<div class="form-row">
<label>难度</label>
<div class="difficulty-picker" id="difficultyPicker">
<button class="diff-btn" data-diff="简单">简单</button>
<button class="diff-btn active" data-diff="中等">中等</button>
<button class="diff-btn" data-diff="困难">困难</button>
</div>
</div>
</div>
<div class="modal-foot">
<button class="btn btn-ghost" id="qModalCancel">取消</button>
<button class="btn btn-primary" id="qModalSave">保存</button>
</div>
</div>
</div>
<!-- Toast 提示 -->
<div class="toast" id="toast" hidden></div>
<script>
(function () {
'use strict';
/* ============ 常量 ============ */
const STORAGE_KEY = 'tongxue-dianming-data-v1';
/* ============ 头像生成(平移自点名端) ============ */
const AVATAR_PALETTES = [
{ bg: '#e8a838', bg2: '#c8861a', fg: '#fff', accent: '#fff' },
{ bg: '#3a8c7a', bg2: '#2a6b5c', fg: '#fff', accent: '#f4c668' },
{ bg: '#c84b4b', bg2: '#a83a3a', fg: '#fff', accent: '#f4c668' },
{ bg: '#1a2238', bg2: '#0f1525', fg: '#fff', accent: '#e8a838' },
{ bg: '#6b5b95', bg2: '#524675', fg: '#fff', accent: '#f4c668' },
{ bg: '#88a36e', bg2: '#6b8455', fg: '#fff', accent: '#fff' },
{ bg: '#d97e4f', bg2: '#b8653a', fg: '#fff', accent: '#fff' },
{ bg: '#4a7c8c', bg2: '#355f6c', fg: '#fff', accent: '#f4c668' },
{ bg: '#b8558c', bg2: '#944471', fg: '#fff', accent: '#fff' },
{ bg: '#5c6b73', bg2: '#445259', fg: '#fff', accent: '#e8a838' },
{ bg: '#c9974a', bg2: '#a87d35', fg: '#fff', accent: '#1a2238' },
{ bg: '#3d6b7d', bg2: '#2c5260', fg: '#fff', accent: '#f4c668' },
];
const PATTERNS = ['dots', 'stars', 'books', 'pencil', 'waves', 'grid'];
function makeAvatar(name, avatarIdx) {
const idx = ((avatarIdx % 12) + 12) % 12;
const p = AVATAR_PALETTES[idx];
const pattern = PATTERNS[idx % PATTERNS.length];
const ch = (name || '?').trim().charAt(0) || '?';
const gid = 'g' + idx + '_' + Math.random().toString(36).slice(2, 7);
let patternSvg = '';
switch (pattern) {
case 'dots':
patternSvg = `<g opacity="0.18" fill="${p.accent}"><circle cx="20" cy="20" r="2.5"/><circle cx="80" cy="20" r="2.5"/><circle cx="20" cy="80" r="2.5"/><circle cx="80" cy="80" r="2.5"/><circle cx="50" cy="15" r="2"/><circle cx="15" cy="50" r="2"/><circle cx="85" cy="50" r="2"/><circle cx="50" cy="85" r="2"/></g>`;
break;
case 'stars':
patternSvg = `<g opacity="0.2" fill="${p.accent}"><path d="M18 18l2 5 5 2-5 2-2 5-2-5-5-2 5-2z"/><path d="M82 78l2 5 5 2-5 2-2 5-2-5-5-2 5-2z"/><path d="M82 22l1.5 4 4 1.5-4 1.5-1.5 4-1.5-4-4-1.5 4-1.5z"/></g>`;
break;
case 'books':
patternSvg = `<g opacity="0.2" fill="${p.accent}"><rect x="12" y="74" width="18" height="12" rx="1"/><rect x="32" y="74" width="18" height="12" rx="1"/><rect x="52" y="74" width="18" height="12" rx="1"/></g>`;
break;
case 'pencil':
patternSvg = `<g opacity="0.2" fill="${p.accent}"><path d="M78 16l6 6-20 20-6 0 0-6z"/><rect x="76" y="14" width="4" height="8" transform="rotate(45 78 18)"/></g>`;
break;
case 'waves':
patternSvg = `<g opacity="0.18" stroke="${p.accent}" stroke-width="2.5" fill="none" stroke-linecap="round"><path d="M10 78 Q30 70 50 78 T90 78"/><path d="M10 86 Q30 78 50 86 T90 86"/></g>`;
break;
case 'grid':
patternSvg = `<g opacity="0.12" stroke="${p.accent}" stroke-width="1"><line x1="25" y1="10" x2="25" y2="90"/><line x1="50" y1="10" x2="50" y2="90"/><line x1="75" y1="10" x2="75" y2="90"/><line x1="10" y1="25" x2="90" y2="25"/><line x1="10" y1="50" x2="90" y2="50"/><line x1="10" y1="75" x2="90" y2="75"/></g>`;
break;
}
return `<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="${gid}" x1="0" y1="0" x2="1" y2="1"><stop offset="0%" stop-color="${p.bg}"/><stop offset="100%" stop-color="${p.bg2}"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#${gid})"/>${patternSvg}<text x="50" y="58" text-anchor="middle" font-family="'Noto Serif SC', serif" font-size="42" font-weight="900" fill="${p.fg}">${escapeXml(ch)}</text></svg>`;
}
function escapeXml(s) {
return String(s).replace(/[<>&"']/g, c => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;', "'": '&apos;' }[c]));
}
function escapeHtml(s) {
return String(s).replace(/[<>&"']/g, c => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;', "'": '&#39;' }[c]));
}
/* ============ 学科配色 ============ */
const SUBJECT_COLORS = {
'语文': '#c84b4b', '数学': '#3a8c7a', '英语': '#4a7c8c',
'物理': '#6b5b95', '化学': '#d97e4f', '生物': '#88a36e',
'历史': '#b8558c', '地理': '#5c6b73', '政治': '#c9974a',
};
function subjectColor(sub) { return SUBJECT_COLORS[sub] || '#5c6b73'; }
/* ============ 数据存储(与点名端共享 localStorage) ============ */
const Store = {
students: [],
questions: [],
history: [],
load() {
// 优先从后端 API 读取;失败回退本地缓存(与点名端共享同一份数据)
return fetch('/API/RollCall/Data', { headers: { 'Accept': 'application/json' } })
.then(r => r.json())
.then(data => {
this.students = Array.isArray(data.students) ? data.students : [];
this.questions = Array.isArray(data.questions) ? data.questions : [];
this.history = Array.isArray(data.history) ? data.history : [];
this.saveLocal();
})
.catch(() => { this.loadLocal(); });
},
save() {
// 先写本地缓存(立即生效),再异步同步后端
this.saveLocal();
fetch('/API/RollCall/Data', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
students: this.students,
questions: this.questions,
history: this.history,
}),
}).catch(e => console.warn('后端保存失败(已存本地缓存)', e));
},
loadLocal() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
const data = JSON.parse(raw);
this.students = Array.isArray(data.students) ? data.students : [];
this.questions = Array.isArray(data.questions) ? data.questions : [];
this.history = Array.isArray(data.history) ? data.history : [];
}
} catch (e) { console.warn('加载本地缓存失败', e); }
},
saveLocal() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify({
students: this.students,
questions: this.questions,
history: this.history,
}));
} catch (e) { console.warn('保存本地缓存失败', e); }
},
getGroups() {
const set = new Set();
this.students.forEach(s => { if (s.group) set.add(s.group); });
return Array.from(set);
},
getSubjects() {
const set = new Set();
this.questions.forEach(q => { if (q.subject) set.add(q.subject); });
return Array.from(set);
},
addStudent(s) {
s.id = 's' + Date.now() + Math.random().toString(36).slice(2, 5);
this.students.push(s);
this.save();
},
updateStudent(id, patch) {
const s = this.students.find(x => x.id === id);
if (s) { Object.assign(s, patch); this.save(); }
},
removeStudent(id) {
this.students = this.students.filter(x => x.id !== id);
this.save();
},
addQuestion(q) {
q.id = 'q' + Date.now() + Math.random().toString(36).slice(2, 5);
this.questions.push(q);
this.save();
},
updateQuestion(id, patch) {
const q = this.questions.find(x => x.id === id);
if (q) { Object.assign(q, patch); this.save(); }
},
removeQuestion(id) {
this.questions = this.questions.filter(x => x.id !== id);
this.save();
},
};
/* ============ DOM 引用 ============ */
const $ = (id) => document.getElementById(id);
const els = {
tabs: document.querySelectorAll('.tab'),
views: document.querySelectorAll('.view'),
// manage
addStudentBtn: $('addStudentBtn'),
exportBtn: $('exportBtn'),
importBtn: $('importBtn'),
importInput: $('importInput'),
searchInput: $('searchInput'),
studentTbody: $('studentTbody'),
manageEmpty: $('manageEmpty'),
// question management
addQuestionBtn: $('addQuestionBtn'),
exportQBtn: $('exportQBtn'),
importQBtn: $('importQBtn'),
importQInput: $('importQInput'),
qSearchInput: $('qSearchInput'),
qSubjectFilter: $('qSubjectFilter'),
questionTbody: $('questionTbody'),
questionEmpty: $('questionEmpty'),
// student modal
studentModal: $('studentModal'),
modalTitle: $('modalTitle'),
modalClose: $('modalClose'),
modalCancel: $('modalCancel'),
modalSave: $('modalSave'),
formName: $('formName'),
formNo: $('formNo'),
formGroup: $('formGroup'),
groupList: $('groupList'),
formNote: $('formNote'),
avatarPreview: $('avatarPreview'),
avatarOptions: $('avatarOptions'),
// question modal
questionModal: $('questionModal'),
qModalTitle: $('qModalTitle'),
qModalClose: $('qModalClose'),
qModalCancel: $('qModalCancel'),
qModalSave: $('qModalSave'),
qFormSubject: $('qFormSubject'),
qFormContent: $('qFormContent'),
difficultyPicker: $('difficultyPicker'),
subjectList: $('subjectList'),
// toast
toast: $('toast'),
};
/* ============ 工具 ============ */
let toastTimer = null;
function toast(msg, type) {
els.toast.textContent = msg;
els.toast.className = 'toast' + (type ? ' ' + type : '');
els.toast.hidden = false;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => { els.toast.hidden = true; }, 2200);
}
function uid() { return 's' + Date.now() + Math.random().toString(36).slice(2, 6); }
/* ============ 视图切换 ============ */
function switchView(name) {
els.tabs.forEach(t => t.classList.toggle('active', t.dataset.view === name));
els.views.forEach(v => v.classList.toggle('active', v.id === 'view-' + name));
if (name === 'manage') renderManage();
if (name === 'question') renderQuestion();
}
/* ============ 渲染:学生管理 ============ */
function renderManage() {
const kw = (els.searchInput.value || '').trim().toLowerCase();
let list = Store.students;
if (kw) list = list.filter(s => s.name.toLowerCase().includes(kw) || (s.no || '').toLowerCase().includes(kw) || (s.group || '').toLowerCase().includes(kw));
els.groupList.innerHTML = Store.getGroups().map(g => `<option value="${escapeHtml(g)}">`).join('');
if (Store.students.length === 0) {
els.studentTbody.innerHTML = '';
els.manageEmpty.hidden = false;
return;
}
els.manageEmpty.hidden = true;
els.studentTbody.innerHTML = list.map(s => `
<tr data-id="${s.id}">
<td><div class="row-avatar">${makeAvatar(s.name, s.avatar || 0)}</div></td>
<td><strong>${escapeHtml(s.name)}</strong></td>
<td>${escapeHtml(s.no || '—')}</td>
<td>${s.group ? `<span class="group-tag">${escapeHtml(s.group)}</span>` : '—'}</td>
<td>${escapeHtml(s.note || '—')}</td>
<td><div class="row-actions">
<button class="icon-btn" data-act="edit" title="编辑">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4z"/></svg>
</button>
<button class="icon-btn danger" data-act="del" title="删除">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6M14 11v6"/></svg>
</button>
</div></td>
</tr>`).join('');
}
/* ============ 渲染:题目管理 ============ */
function renderQuestion() {
const kw = (els.qSearchInput.value || '').trim().toLowerCase();
const subj = els.qSubjectFilter.value;
let list = Store.questions;
if (subj) list = list.filter(q => q.subject === subj);
if (kw) list = list.filter(q => q.content.toLowerCase().includes(kw) || q.subject.toLowerCase().includes(kw));
if (Store.questions.length === 0) {
els.questionTbody.innerHTML = '';
els.questionEmpty.hidden = false;
return;
}
els.questionEmpty.hidden = true;
els.questionTbody.innerHTML = list.map(q => `
<tr data-id="${q.id}">
<td><span class="subject-tag" style="background:${subjectColor(q.subject)}">${escapeHtml(q.subject || '未分类')}</span></td>
<td>${escapeHtml(q.content)}</td>
<td><span class="diff-tag ${escapeHtml(q.difficulty || '中等')}">${escapeHtml(q.difficulty || '中等')}</span></td>
<td><div class="row-actions">
<button class="icon-btn" data-act="edit" title="编辑">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4z"/></svg>
</button>
<button class="icon-btn danger" data-act="del" title="删除">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6M14 11v6"/></svg>
</button>
</div></td>
</tr>`).join('');
}
/* ============ 渲染:学科下拉 ============ */
function renderSubjectSelects() {
const subjects = Store.getSubjects();
const opts = '<option value="">全部学科</option>' +
subjects.map(s => `<option value="${escapeHtml(s)}">${escapeHtml(s)}</option>`).join('');
els.qSubjectFilter.innerHTML = opts;
els.subjectList.innerHTML = subjects.map(s => `<option value="${escapeHtml(s)}">`).join('');
}
/* ============ 弹窗:添加/编辑学生 ============ */
let editingId = null;
let selectedAvatar = 0;
function openModal(id) {
editingId = id || null;
els.modalTitle.textContent = id ? '编辑学生' : '添加学生';
els.groupList.innerHTML = Store.getGroups().map(g => `<option value="${escapeHtml(g)}">`).join('');
if (id) {
const s = Store.students.find(x => x.id === id);
els.formName.value = s.name;
els.formNo.value = s.no || '';
els.formGroup.value = s.group || '';
els.formNote.value = s.note || '';
selectedAvatar = s.avatar || 0;
} else {
els.formName.value = '';
els.formNo.value = '';
els.formGroup.value = '';
els.formNote.value = '';
selectedAvatar = Math.floor(Math.random() * 12);
}
renderAvatarPicker();
els.studentModal.hidden = false;
setTimeout(() => els.formName.focus(), 100);
}
function closeModal() {
els.studentModal.hidden = true;
editingId = null;
}
function renderAvatarPicker() {
els.avatarPreview.innerHTML = makeAvatar(els.formName.value || '同', selectedAvatar);
els.avatarOptions.innerHTML = '';
for (let i = 0; i < 12; i++) {
const opt = document.createElement('div');
opt.className = 'avatar-opt' + (i === selectedAvatar ? ' selected' : '');
opt.innerHTML = makeAvatar(els.formName.value || '同', i);
opt.onclick = () => { selectedAvatar = i; renderAvatarPicker(); };
els.avatarOptions.appendChild(opt);
}
}
function saveStudent() {
const name = els.formName.value.trim();
if (!name) { toast('请输入姓名', 'error'); return; }
const data = {
name,
no: els.formNo.value.trim(),
group: els.formGroup.value.trim(),
note: els.formNote.value.trim(),
avatar: selectedAvatar,
};
if (editingId) {
Store.updateStudent(editingId, data);
toast('已更新', 'success');
} else {
Store.addStudent(data);
toast('已添加', 'success');
}
closeModal();
renderManage();
}
/* ============ 弹窗:添加/编辑题目 ============ */
let editingQId = null;
let selectedDifficulty = '中等';
function openQuestionModal(id) {
editingQId = id || null;
els.qModalTitle.textContent = id ? '编辑题目' : '添加题目';
els.subjectList.innerHTML = Store.getSubjects().map(s => `<option value="${escapeHtml(s)}">`).join('');
if (id) {
const q = Store.questions.find(x => x.id === id);
els.qFormSubject.value = q.subject || '';
els.qFormContent.value = q.content || '';
selectedDifficulty = q.difficulty || '中等';
} else {
els.qFormSubject.value = '';
els.qFormContent.value = '';
selectedDifficulty = '中等';
}
updateDifficultyUI();
els.questionModal.hidden = false;
setTimeout(() => els.qFormSubject.focus(), 100);
}
function closeQuestionModal() {
els.questionModal.hidden = true;
editingQId = null;
}
function updateDifficultyUI() {
els.difficultyPicker.querySelectorAll('.diff-btn').forEach(b => {
b.classList.toggle('active', b.dataset.diff === selectedDifficulty);
});
}
function saveQuestion() {
const subject = els.qFormSubject.value.trim();
const content = els.qFormContent.value.trim();
if (!subject) { toast('请输入学科', 'error'); return; }
if (!content) { toast('请输入题目内容', 'error'); return; }
const data = { subject, content, difficulty: selectedDifficulty };
if (editingQId) {
Store.updateQuestion(editingQId, data);
toast('已更新', 'success');
} else {
Store.addQuestion(data);
toast('已添加', 'success');
}
closeQuestionModal();
renderQuestion();
renderSubjectSelects();
}
/* ============ 导入导出 ============ */
function exportJSON() {
if (Store.students.length === 0 && Store.questions.length === 0) { toast('暂无数据可导出', 'error'); return; }
const data = { students: Store.students, questions: Store.questions, history: Store.history, exportedAt: new Date().toISOString() };
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = '同学点名数据_' + new Date().toISOString().slice(0, 10) + '.json';
a.click();
URL.revokeObjectURL(url);
toast('已导出', 'success');
}
function importJSON(file) {
const reader = new FileReader();
reader.onload = (e) => {
try {
const data = JSON.parse(e.target.result);
if (!Array.isArray(data.students)) throw new Error('格式错误');
Store.students = data.students.map(s => ({
id: s.id || uid(),
name: String(s.name || ''),
no: String(s.no || ''),
group: String(s.group || ''),
note: String(s.note || ''),
avatar: Number(s.avatar) || 0,
}));
Store.questions = Array.isArray(data.questions) ? data.questions.map(q => ({
id: q.id || uid(),
subject: String(q.subject || ''),
content: String(q.content || ''),
difficulty: String(q.difficulty || '中等'),
})) : [];
Store.history = Array.isArray(data.history) ? data.history : [];
Store.save();
renderManage();
renderQuestion();
renderSubjectSelects();
toast('导入成功,共 ' + Store.students.length + ' 人,' + Store.questions.length + ' 题', 'success');
} catch (err) {
toast('导入失败:' + err.message, 'error');
}
};
reader.readAsText(file);
}
function exportQuestions() {
if (Store.questions.length === 0) { toast('暂无题目可导出', 'error'); return; }
const data = { questions: Store.questions, exportedAt: new Date().toISOString() };
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = '题库_' + new Date().toISOString().slice(0, 10) + '.json';
a.click();
URL.revokeObjectURL(url);
toast('已导出题库', 'success');
}
function importQuestions(file) {
const reader = new FileReader();
reader.onload = (e) => {
try {
const data = JSON.parse(e.target.result);
const qs = Array.isArray(data.questions) ? data.questions : (Array.isArray(data) ? data : null);
if (!qs) throw new Error('格式错误');
Store.questions = qs.map(q => ({
id: q.id || uid(),
subject: String(q.subject || ''),
content: String(q.content || ''),
difficulty: String(q.difficulty || '中等'),
}));
Store.save();
renderQuestion();
renderSubjectSelects();
toast('导入成功,共 ' + Store.questions.length + ' 题', 'success');
} catch (err) {
toast('导入失败:' + err.message, 'error');
}
};
reader.readAsText(file);
}
/* ============ 事件绑定 ============ */
function bindEvents() {
els.tabs.forEach(t => t.addEventListener('click', () => switchView(t.dataset.view)));
// 学生管理
els.addStudentBtn.addEventListener('click', () => openModal());
els.exportBtn.addEventListener('click', exportJSON);
els.importBtn.addEventListener('click', () => els.importInput.click());
els.importInput.addEventListener('change', (e) => {
if (e.target.files[0]) importJSON(e.target.files[0]);
e.target.value = '';
});
els.searchInput.addEventListener('input', renderManage);
els.studentTbody.addEventListener('click', (e) => {
const btn = e.target.closest('[data-act]');
if (!btn) return;
const tr = btn.closest('tr');
const id = tr.dataset.id;
if (btn.dataset.act === 'edit') openModal(id);
else if (btn.dataset.act === 'del') {
if (confirm('确定删除该学生?')) {
Store.removeStudent(id);
renderManage();
toast('已删除', 'success');
}
}
});
// 题目管理
els.addQuestionBtn.addEventListener('click', () => openQuestionModal());
els.exportQBtn.addEventListener('click', exportQuestions);
els.importQBtn.addEventListener('click', () => els.importQInput.click());
els.importQInput.addEventListener('change', (e) => {
if (e.target.files[0]) importQuestions(e.target.files[0]);
e.target.value = '';
});
els.qSearchInput.addEventListener('input', renderQuestion);
els.qSubjectFilter.addEventListener('change', renderQuestion);
els.questionTbody.addEventListener('click', (e) => {
const btn = e.target.closest('[data-act]');
if (!btn) return;
const tr = btn.closest('tr');
const id = tr.dataset.id;
if (btn.dataset.act === 'edit') openQuestionModal(id);
else if (btn.dataset.act === 'del') {
if (confirm('确定删除该题目?')) {
Store.removeQuestion(id);
renderQuestion();
renderSubjectSelects();
toast('已删除', 'success');
}
}
});
// 题目弹窗
els.qModalClose.addEventListener('click', closeQuestionModal);
els.qModalCancel.addEventListener('click', closeQuestionModal);
els.qModalSave.addEventListener('click', saveQuestion);
els.questionModal.addEventListener('click', (e) => { if (e.target === els.questionModal) closeQuestionModal(); });
els.difficultyPicker.addEventListener('click', (e) => {
const btn = e.target.closest('.diff-btn');
if (!btn) return;
selectedDifficulty = btn.dataset.diff;
updateDifficultyUI();
});
// 学生弹窗
els.modalClose.addEventListener('click', closeModal);
els.modalCancel.addEventListener('click', closeModal);
els.modalSave.addEventListener('click', saveStudent);
els.studentModal.addEventListener('click', (e) => { if (e.target === els.studentModal) closeModal(); });
els.formName.addEventListener('input', renderAvatarPicker);
}
/* ============ 初始化 ============ */
function init() {
// 数据改为后端 API 加载(失败回退本地缓存),加载完成后再渲染
Store.load().then(() => {
bindEvents();
renderManage();
renderQuestion();
renderSubjectSelects();
});
}
document.addEventListener('DOMContentLoaded', init);
})();
</script>
</body>
</html>