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 二进制分发目录。
790 lines
34 KiB
HTML
790 lines
34 KiB
HTML
<!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 => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' }[c]));
|
||
}
|
||
function escapeHtml(s) {
|
||
return String(s).replace(/[<>&"']/g, c => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' }[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>
|