Files
SMANAGE/TempLate/rollcall/script.js
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

1034 lines
38 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.
/* ============================================================
* 同学点名 - 课堂随机抽取系统
* 原生 HTML/CSS/JS,localStorage 持久化
* ============================================================ */
(function () {
'use strict';
/* ============ 常量 ============ */
const STORAGE_KEY = 'tongxue-dianming-data-v1';
const SETTINGS_KEY = 'tongxue-dianming-settings-v1';
/* ============ 默认示例数据 ============ */
const DEMO_STUDENTS = [
{ id: 's1', name: '张明轩', no: '2024001', note: '班长', avatar: 0, group: '第一组' },
{ id: 's2', name: '李思琪', no: '2024002', note: '学习委员', avatar: 1, group: '第一组' },
{ id: 's3', name: '王浩然', no: '2024003', note: '', avatar: 2, group: '第一组' },
{ id: 's4', name: '陈雨桐', no: '2024004', note: '', avatar: 3, group: '第二组' },
{ id: 's5', name: '刘子涵', no: '2024005', note: '体育委员', avatar: 4, group: '第二组' },
{ id: 's6', name: '杨欣怡', no: '2024006', note: '', avatar: 5, group: '第二组' },
{ id: 's7', name: '黄宇辰', no: '2024007', note: '', avatar: 6, group: '第三组' },
{ id: 's8', name: '赵梓萱', no: '2024008', note: '', avatar: 7, group: '第三组' },
{ id: 's9', name: '周奕辰', no: '2024009', note: '', avatar: 8, group: '第三组' },
{ id: 's10', name: '吴沐瑶', no: '2024010', note: '文艺委员', avatar: 9, group: '第四组' },
{ id: 's11', name: '徐子睿', no: '2024011', note: '', avatar: 10, group: '第四组' },
{ id: 's12', name: '孙若汐', no: '2024012', note: '', avatar: 11, group: '第四组' },
];
const DEMO_QUESTIONS = [
{ id: 'q1', subject: '语文', content: '请背诵《静夜思》并解释"举头望明月"的意境。', difficulty: '简单' },
{ id: 'q2', subject: '语文', content: '分析《背影》一文中父亲形象的特点及其表现手法。', difficulty: '中等' },
{ id: 'q3', subject: '语文', content: '请举例说明比喻与拟人修辞手法的区别。', difficulty: '简单' },
{ id: 'q4', subject: '数学', content: '已知函数 f(x) = x² - 4x + 3,求其顶点坐标和对称轴。', difficulty: '中等' },
{ id: 'q5', subject: '数学', content: '证明:三角形任意两边之和大于第三边。', difficulty: '困难' },
{ id: 'q6', subject: '数学', content: '计算:sin30° + cos60° - tan45° 的值。', difficulty: '简单' },
{ id: 'q7', subject: '英语', content: 'Please introduce yourself in English within 5 sentences.', difficulty: '简单' },
{ id: 'q8', subject: '英语', content: 'Translate the following sentence: "Knowledge is power."', difficulty: '中等' },
{ id: 'q9', subject: '英语', content: 'Explain the difference between "have been to" and "have gone to".', difficulty: '困难' },
{ id: 'q10', subject: '物理', content: '解释牛顿第一定律的内容,并举例说明惯性现象。', difficulty: '中等' },
{ id: 'q11', subject: '物理', content: '一个物体从高处自由下落,3秒后落地,求高度(g=10m/s²)。', difficulty: '中等' },
{ id: 'q12', subject: '历史', content: '简述丝绸之路的开通及其历史意义。', difficulty: '中等' },
];
// 学科配色
const SUBJECT_COLORS = {
'语文': '#c84b4b', '数学': '#3a8c7a', '英语': '#4a7c8c',
'物理': '#6b5b95', '化学': '#d97e4f', '生物': '#88a36e',
'历史': '#b8558c', '地理': '#5c6b73', '政治': '#c9974a',
};
/* ============ SVG 头像生成 ============ */
// 12 种配色方案
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'];
/**
* 生成 SVG 头像字符串
* @param {string} name - 学生姓名
* @param {number} avatarIdx - 头像索引 0-11
*/
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]));
}
/* ============ 数据存储 ============ */
const Store = {
students: [],
questions: [],
history: [], // [{id, studentId, name, time}]
pickedIds: new Set(), // 本次课堂已抽中学生
pickedQuestionIds: new Set(), // 本次课堂已抽中题目
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); }
},
getSettings() {
try {
const raw = localStorage.getItem(SETTINGS_KEY);
if (raw) return Object.assign(defaultSettings(), JSON.parse(raw));
} catch (e) {}
return defaultSettings();
},
saveSettings(s) {
try { localStorage.setItem(SETTINGS_KEY, JSON.stringify(s)); } catch (e) {}
},
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();
},
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);
},
resetPicked() {
this.pickedIds.clear();
this.pickedQuestionIds.clear();
this.history = [];
this.save();
},
};
function defaultSettings() {
return { speed: 3, sound: false, history: true, mode: 'single', multiCount: 3, pickPerson: true, pickQuestion: false, questionNoRepeat: false };
}
let settings = Store.getSettings();
/* ============ DOM 引用 ============ */
const $ = (id) => document.getElementById(id);
const els = {
// tabs
tabs: document.querySelectorAll('.tab'),
views: document.querySelectorAll('.view'),
countBadge: $('countBadge'),
refreshBtn: $('refreshBtn'),
// roll
rollerCard: $('rollerCard'),
rollerAvatar: $('rollerAvatar'),
rollerName: $('rollerName'),
rollerTag: $('rollerTag'),
rollBtn: $('rollBtn'),
resetBtn: $('resetBtn'),
modeSwitch: $('modeSwitch'),
multiConfig: $('multiConfig'),
stepVal: $('stepVal'),
stepUp: $('stepUp'),
stepDown: $('stepDown'),
confetti: $('confetti'),
resultToast: $('resultToast'),
resultName: $('resultName'),
groupSelect: $('groupSelect'),
// mode toggles
modeToggles: document.querySelectorAll('.mode-toggle'),
personControls: $('personControls'),
questionControls: $('questionControls'),
// multi results
multiResults: $('multiResults'),
multiResultsCount: $('multiResultsCount'),
multiResultsGrid: $('multiResultsGrid'),
// question result
questionResult: $('questionResult'),
subjectSelect: $('subjectSelect'),
questionDisplay: $('questionDisplay'),
questionNoRepeat: $('questionNoRepeat'),
// wall
wall: $('wall'),
wallEmpty: $('wallEmpty'),
cntAll: $('cntAll'),
cntPending: $('cntPending'),
cntPicked: $('cntPicked'),
loadDemoBtn: $('loadDemoBtn'),
// history
historyList: $('historyList'),
historyEmpty: $('historyEmpty'),
clearHistoryBtn: $('clearHistoryBtn'),
// settings
speedRange: $('speedRange'),
speedVal: $('speedVal'),
soundToggle: $('soundToggle'),
historyToggle: $('historyToggle'),
loadDemoBtn2: $('loadDemoBtn2'),
clearAllBtn: $('clearAllBtn'),
// 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 fmtTime(t) {
const d = new Date(t);
return d.getHours().toString().padStart(2, '0') + ':' + d.getMinutes().toString().padStart(2, '0') + ':' + d.getSeconds().toString().padStart(2, '0');
}
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 === 'settings') renderSettings();
}
/* ============ 渲染:分组下拉 ============ */
function renderGroupSelect() {
const groups = Store.getGroups();
const current = els.groupSelect.value;
els.groupSelect.innerHTML = '<option value="">全部学生</option>' +
groups.map(g => `<option value="${escapeHtml(g)}"${g === current ? ' selected' : ''}>${escapeHtml(g)}</option>`).join('');
}
/* ============ 渲染:学科下拉 ============ */
function renderSubjectSelects() {
const subjects = Store.getSubjects();
const opts = '<option value="">全部学科</option>' +
subjects.map(s => `<option value="${escapeHtml(s)}">${escapeHtml(s)}</option>`).join('');
els.subjectSelect.innerHTML = opts;
}
function subjectColor(sub) {
return SUBJECT_COLORS[sub] || '#5c6b73';
}
/* ============ 模式开关 UI 更新 ============ */
function updateModeToggleUI() {
els.modeToggles.forEach(btn => {
const key = btn.dataset.toggle === 'person' ? 'pickPerson' : 'pickQuestion';
btn.classList.toggle('active', settings[key]);
});
els.personControls.hidden = !settings.pickPerson;
els.questionControls.hidden = !settings.pickQuestion;
// 多人数量配置也跟随抽人模式
if (settings.pickPerson) {
els.multiConfig.hidden = settings.mode !== 'multi';
} else {
els.multiConfig.hidden = true;
}
// 更新按钮文字
if (!rolling) {
els.rollBtn.querySelector('.btn-text').textContent = getRollBtnText();
}
}
/* ============ 渲染:学生卡片墙 ============ */
let wallFilter = 'all';
function renderWall() {
const list = Store.students;
const pending = list.filter(s => !Store.pickedIds.has(s.id));
const picked = list.filter(s => Store.pickedIds.has(s.id));
els.cntAll.textContent = list.length;
els.cntPending.textContent = pending.length;
els.cntPicked.textContent = picked.length;
els.countBadge.textContent = list.length + ' 人';
let show = list;
if (wallFilter === 'pending') show = pending;
else if (wallFilter === 'picked') show = picked;
els.wall.innerHTML = '';
if (list.length === 0) {
els.wallEmpty.hidden = false;
els.wallEmpty.querySelector('button').onclick = loadDemo;
return;
}
els.wallEmpty.hidden = true;
show.forEach(s => {
const card = document.createElement('div');
card.className = 'wall-card' + (Store.pickedIds.has(s.id) ? ' picked' : '');
card.dataset.id = s.id;
card.innerHTML = `
<div class="wall-avatar">${makeAvatar(s.name, s.avatar || 0)}</div>
<div class="wall-name">${escapeHtml(s.name)}</div>
<div class="wall-no">${escapeHtml(s.no || '')}</div>${s.group ? `<div class="wall-group">${escapeHtml(s.group)}</div>` : ''}`;
els.wall.appendChild(card);
});
}
function highlightWallCard(id) {
document.querySelectorAll('.wall-card').forEach(c => c.classList.remove('current'));
if (id) {
const card = els.wall.querySelector(`.wall-card[data-id="${id}"]`);
if (card) card.classList.add('current');
}
}
/* ============ 渲染:历史记录 ============ */
function renderHistory() {
const list = Store.history;
if (list.length === 0) {
els.historyList.innerHTML = '';
els.historyEmpty.hidden = false;
return;
}
els.historyEmpty.hidden = true;
els.historyList.innerHTML = list.map((h, i) => {
const s = Store.students.find(x => x.id === h.studentId) || { avatar: 0 };
return `<div class="history-item">
<span class="history-idx">${i + 1}</span>
<div class="history-avatar">${makeAvatar(h.name, s.avatar || 0)}</div>
<span class="history-name">${escapeHtml(h.name)}</span>
<span class="history-no">${escapeHtml(h.no || '')}</span>
<span class="history-time">${fmtTime(h.time)}</span>
</div>`;
}).join('');
}
/* ============ 渲染:设置 ============ */
function renderSettings() {
els.speedRange.value = settings.speed;
els.speedVal.textContent = speedLabel(settings.speed);
els.soundToggle.checked = settings.sound;
els.historyToggle.checked = settings.history;
els.questionNoRepeat.checked = settings.questionNoRepeat;
}
function speedLabel(v) {
return ['', '极快', '快速', '标准', '舒缓', '慢速'][v] || '标准';
}
/* ============ 抽取动画核心 ============ */
let rolling = false;
let multiResults = []; // 多人模式暂存结果
function getCandidates() {
const group = els.groupSelect.value;
let pool = Store.students;
if (group) pool = pool.filter(s => s.group === group);
if (settings.mode === 'norepeat') {
return pool.filter(s => !Store.pickedIds.has(s.id));
}
return pool;
}
function getQuestionPool() {
const subj = els.subjectSelect.value;
let pool = Store.questions;
if (subj) pool = pool.filter(q => q.subject === subj);
if (settings.questionNoRepeat) {
pool = pool.filter(q => !Store.pickedQuestionIds.has(q.id));
}
return pool;
}
function startRoll() {
if (rolling) return;
const doPerson = settings.pickPerson;
const doQuestion = settings.pickQuestion;
if (!doPerson && !doQuestion) {
toast('请至少开启一个模式(抽人或抽题)', 'error');
return;
}
// 预选学生
let personResults = [];
if (doPerson) {
const candidates = getCandidates();
if (candidates.length === 0) {
toast(settings.mode === 'norepeat' ? '所有学生已被抽中,请重置' : '暂无学生数据', 'error');
return;
}
const count = settings.mode === 'multi' ? Math.min(settings.multiCount, candidates.length) : 1;
const pool = candidates.slice();
for (let i = 0; i < count; i++) {
personResults.push(pool.splice(Math.floor(Math.random() * pool.length), 1)[0]);
}
}
// 预选题目
let questionTarget = null;
if (doQuestion) {
const qPool = getQuestionPool();
if (qPool.length === 0) {
if (Store.questions.length === 0) {
toast('暂无题目数据', 'error');
} else if (settings.questionNoRepeat) {
toast('所有题目已被抽完,请重置或关闭不重复', 'error');
} else {
toast('该学科暂无题目', 'error');
}
return;
}
questionTarget = qPool[Math.floor(Math.random() * qPool.length)];
}
rolling = true;
multiResults = [];
els.rollBtn.disabled = true;
els.rollBtn.querySelector('.btn-text').textContent = '抽取中…';
els.resultToast.hidden = true;
els.rollerCard.classList.remove('done');
els.rollerCard.classList.remove('question-mode');
els.rollerCard.classList.add('spinning');
// 清空上一次结果(防止缓存残留)
els.multiResults.hidden = true;
els.multiResultsGrid.innerHTML = '';
els.questionResult.hidden = true;
els.questionDisplay.classList.remove('has-question');
els.questionDisplay.innerHTML = '<span class="question-placeholder">抽取中…</span>';
highlightWallCard(null);
// 清空 roller 卡片内容,避免显示上次结果
els.rollerAvatar.innerHTML = '';
els.rollerName.textContent = '抽取中…';
els.rollerTag.textContent = '';
if (doPerson) {
runPersonRoll(personResults, () => {
if (doQuestion) {
// 人物完成后,揭示题目
setTimeout(() => revealQuestionResult(questionTarget), 500);
}
});
} else if (doQuestion) {
// 仅抽题:在 roller 上做题目滚动动画
runQuestionRoll(questionTarget);
}
}
/* ---- 人物滚动动画 ---- */
function runPersonRoll(results, onComplete) {
const list = Store.students.length > 0 ? Store.students : results;
const durations = [0, 1600, 2200, 3000, 3800, 4600];
const total = settings.mode === 'multi' ? durations[settings.speed] + 600 : durations[settings.speed];
const start = performance.now();
const minInterval = settings.mode === 'multi' ? 160 : 180;
function tick(now) {
const elapsed = now - start;
const progress = Math.min(elapsed / total, 1);
const eased = easeOutCubic(progress);
const interval = 50 + (minInterval - 50) * eased;
if (progress < 1) {
const random = list[Math.floor(Math.random() * list.length)];
showRollerCard(random);
setTimeout(() => requestAnimationFrame(tick), interval);
} else {
finishPersonRoll(results, onComplete);
}
}
requestAnimationFrame(tick);
}
function finishPersonRoll(results, onComplete) {
els.rollerCard.classList.remove('spinning');
els.rollerCard.classList.add('done');
if (settings.mode === 'multi') {
multiResults = results.slice();
els.multiResults.hidden = false;
els.multiResultsCount.textContent = '共 ' + results.length + ' 人';
els.multiResultsGrid.innerHTML = '';
revealMulti(0, onComplete);
} else {
const target = results[0];
showRollerCard(target);
els.resultName.textContent = target.name;
els.resultToast.hidden = false;
highlightWallCard(target.id);
recordPick(target);
fireConfetti();
playSound();
setTimeout(() => { els.resultToast.hidden = true; }, 3000);
if (onComplete) {
rolling = false;
els.rollBtn.disabled = false;
els.rollBtn.querySelector('.btn-text').textContent = getRollBtnText();
onComplete();
}
}
}
function revealMulti(i, onComplete) {
if (i >= multiResults.length) {
els.resultName.textContent = multiResults.map(s => s.name).join('、');
els.resultToast.hidden = false;
fireConfetti();
playSound();
setTimeout(() => { els.resultToast.hidden = true; }, 3500);
rolling = false;
els.rollBtn.disabled = false;
els.rollBtn.querySelector('.btn-text').textContent = getRollBtnText();
if (onComplete) onComplete();
return;
}
const target = multiResults[i];
showRollerCard(target);
highlightWallCard(target.id);
recordPick(target);
addMultiResultCard(target, i + 1);
playSound();
fireConfetti(true);
setTimeout(() => revealMulti(i + 1, onComplete), 900);
}
/* ---- 题目滚动动画 ---- */
function runQuestionRoll(target) {
els.rollerCard.classList.add('question-mode');
const list = Store.questions;
const durations = [0, 1400, 2000, 2800, 3600, 4400];
const total = durations[settings.speed];
const start = performance.now();
function tick(now) {
const elapsed = now - start;
const progress = Math.min(elapsed / total, 1);
const eased = easeOutCubic(progress);
const interval = 60 + (200 - 60) * eased;
if (progress < 1) {
const random = list[Math.floor(Math.random() * list.length)];
showRollerQuestion(random);
setTimeout(() => requestAnimationFrame(tick), interval);
} else {
finishQuestionRoll(target);
}
}
requestAnimationFrame(tick);
}
function finishQuestionRoll(target) {
els.rollerCard.classList.remove('spinning');
els.rollerCard.classList.add('done');
showRollerQuestion(target);
fireConfetti();
playSound();
revealQuestionResult(target);
rolling = false;
els.rollBtn.disabled = false;
els.rollBtn.querySelector('.btn-text').textContent = getRollBtnText();
}
function revealQuestionResult(question) {
// 记录已抽中题目(不重复模式)
if (settings.questionNoRepeat) {
Store.pickedQuestionIds.add(question.id);
}
els.questionResult.hidden = false;
els.questionDisplay.classList.add('has-question');
// 重置动画
els.questionDisplay.style.animation = 'none';
void els.questionDisplay.offsetHeight;
els.questionDisplay.style.animation = '';
els.questionDisplay.innerHTML = `
<div class="question-content">
<div class="question-meta">
<span class="question-subject-tag" style="background:${subjectColor(question.subject)}">${escapeHtml(question.subject)}</span>
<span class="question-diff-tag ${escapeHtml(question.difficulty || '中等')}">${escapeHtml(question.difficulty || '中等')}</span>
</div>
<div class="question-text">${escapeHtml(question.content)}</div>
</div>`;
playSound();
}
function showRollerCard(student) {
els.rollerCard.classList.remove('question-mode');
els.rollerAvatar.innerHTML = makeAvatar(student.name, student.avatar || 0);
els.rollerName.textContent = student.name;
els.rollerTag.textContent = student.no || '';
}
function showRollerQuestion(question) {
els.rollerCard.classList.add('question-mode');
els.rollerAvatar.innerHTML = `<div class="roller-q-icon" style="background:${subjectColor(question.subject)};width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-family:var(--serif);font-size:48px;font-weight:900;color:#fff;border-radius:22px;">${escapeHtml((question.subject || '?').charAt(0))}</div>`;
els.rollerName.textContent = question.subject || '题目';
const content = question.content || '';
els.rollerTag.textContent = content.length > 30 ? content.slice(0, 30) + '…' : content;
}
function getRollBtnText() {
const p = settings.pickPerson, q = settings.pickQuestion;
if (p && q) return '开始抽取';
if (p) return '开始点名';
if (q) return '开始抽题';
return '开始';
}
function addMultiResultCard(student, idx) {
const card = document.createElement('div');
card.className = 'multi-result-card';
card.innerHTML = `
<span class="mr-idx">${idx}</span>
<div class="mr-avatar">${makeAvatar(student.name, student.avatar || 0)}</div>
<div class="mr-name">${escapeHtml(student.name)}</div>
<div class="mr-no">${escapeHtml(student.no || '')}</div>`;
els.multiResultsGrid.appendChild(card);
}
function recordPick(student) {
if (settings.mode === 'norepeat') {
Store.pickedIds.add(student.id);
}
if (settings.history) {
Store.history.push({ id: uid(), studentId: student.id, name: student.name, no: student.no, time: Date.now() });
Store.save();
}
renderWall();
renderHistory();
}
function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); }
/* ============ 粒子效果 ============ */
const confettiCtx = els.confetti.getContext('2d');
let confettiParticles = [];
let confettiRaf = null;
function resizeConfetti() {
const r = els.confetti.getBoundingClientRect();
els.confetti.width = r.width * devicePixelRatio;
els.confetti.height = r.height * devicePixelRatio;
confettiCtx.scale(devicePixelRatio, devicePixelRatio);
}
function fireConfetti(small) {
resizeConfetti();
const r = els.confetti.getBoundingClientRect();
const cx = r.width / 2;
const cy = r.height / 2;
const colors = ['#e8a838', '#f4c668', '#3a8c7a', '#c84b4b', '#fff'];
const count = small ? 40 : 80;
for (let i = 0; i < count; i++) {
const angle = (Math.PI * 2 * i) / count + Math.random() * 0.5;
const speed = 3 + Math.random() * 6;
confettiParticles.push({
x: cx, y: cy,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed - 2,
size: 4 + Math.random() * 6,
color: colors[Math.floor(Math.random() * colors.length)],
rot: Math.random() * Math.PI,
vr: (Math.random() - 0.5) * 0.3,
life: 1,
});
}
if (!confettiRaf) animateConfetti();
}
function animateConfetti() {
const r = els.confetti.getBoundingClientRect();
confettiCtx.clearRect(0, 0, r.width, r.height);
confettiParticles = confettiParticles.filter(p => p.life > 0);
confettiParticles.forEach(p => {
p.x += p.vx;
p.y += p.vy;
p.vy += 0.18; // 重力
p.vx *= 0.99;
p.rot += p.vr;
p.life -= 0.012;
confettiCtx.save();
confettiCtx.translate(p.x, p.y);
confettiCtx.rotate(p.rot);
confettiCtx.globalAlpha = Math.max(p.life, 0);
confettiCtx.fillStyle = p.color;
confettiCtx.fillRect(-p.size / 2, -p.size / 2, p.size, p.size * 0.6);
confettiCtx.restore();
});
if (confettiParticles.length > 0) {
confettiRaf = requestAnimationFrame(animateConfetti);
} else {
confettiRaf = null;
confettiCtx.clearRect(0, 0, r.width, r.height);
}
}
/* ============ 音效(Web Audio 合成) ============ */
let audioCtx = null;
function playSound() {
if (!settings.sound) return;
try {
if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
const t = audioCtx.currentTime;
// 两个短音
[880, 1320].forEach((freq, i) => {
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = 'sine';
osc.frequency.value = freq;
gain.gain.setValueAtTime(0, t + i * 0.12);
gain.gain.linearRampToValueAtTime(0.15, t + i * 0.12 + 0.02);
gain.gain.exponentialRampToValueAtTime(0.001, t + i * 0.12 + 0.25);
osc.connect(gain).connect(audioCtx.destination);
osc.start(t + i * 0.12);
osc.stop(t + i * 0.12 + 0.3);
});
} catch (e) {}
}
/* ============ 示例数据 / 清空 ============ */
function loadDemo() {
Store.students = DEMO_STUDENTS.map(s => ({ ...s }));
Store.questions = DEMO_QUESTIONS.map(q => ({ ...q }));
Store.history = [];
Store.pickedIds.clear();
Store.save();
renderWall();
renderHistory();
renderGroupSelect();
renderSubjectSelects();
toast('已加载示例数据', 'success');
}
function clearAll() {
if (!confirm('确定清空所有学生、题目数据与课堂记录?此操作不可恢复。')) return;
Store.students = [];
Store.questions = [];
Store.history = [];
Store.pickedIds.clear();
Store.save();
renderWall();
renderHistory();
renderGroupSelect();
renderSubjectSelects();
toast('已清空', 'success');
}
/* ============ 工具:HTML 转义 ============ */
function escapeHtml(s) {
return String(s).replace(/[<>&"']/g, c => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;', "'": '&#39;' }[c]));
}
/* ============ 事件绑定 ============ */
function bindEvents() {
// 标签切换
els.tabs.forEach(t => t.addEventListener('click', () => switchView(t.dataset.view)));
// 模式切换
els.modeSwitch.querySelectorAll('.mode-btn').forEach(btn => {
btn.addEventListener('click', () => {
els.modeSwitch.querySelectorAll('.mode-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
settings.mode = btn.dataset.mode;
els.multiConfig.hidden = !(settings.pickPerson && settings.mode === 'multi');
Store.saveSettings(settings);
});
});
// 多人数量
els.stepUp.addEventListener('click', () => {
settings.multiCount = Math.min(settings.multiCount + 1, 20);
els.stepVal.textContent = settings.multiCount;
Store.saveSettings(settings);
});
els.stepDown.addEventListener('click', () => {
settings.multiCount = Math.max(settings.multiCount - 1, 1);
els.stepVal.textContent = settings.multiCount;
Store.saveSettings(settings);
});
// 抽取 / 重置
els.rollBtn.addEventListener('click', startRoll);
els.resetBtn.addEventListener('click', () => {
if (rolling) return;
Store.resetPicked();
els.rollerCard.classList.remove('done');
els.rollerCard.classList.remove('question-mode');
els.rollerAvatar.innerHTML = '';
els.rollerName.textContent = '准备就绪';
els.rollerTag.textContent = '点击下方按钮开始';
els.resultToast.hidden = true;
els.multiResults.hidden = true;
els.multiResultsGrid.innerHTML = '';
els.questionResult.hidden = true;
els.questionDisplay.classList.remove('has-question');
els.questionDisplay.innerHTML = '<span class="question-placeholder">点击下方按钮开始抽题</span>';
highlightWallCard(null);
renderWall();
renderHistory();
toast('已重置', 'success');
});
// 分组筛选
els.groupSelect.addEventListener('change', renderWall);
// 模式开关(抽人/抽题)
els.modeToggles.forEach(btn => {
btn.addEventListener('click', () => {
const key = btn.dataset.toggle === 'person' ? 'pickPerson' : 'pickQuestion';
settings[key] = !settings[key];
// 至少保留一个
if (!settings.pickPerson && !settings.pickQuestion) {
settings[key] = true;
toast('至少需要开启一个模式', 'error');
return;
}
Store.saveSettings(settings);
updateModeToggleUI();
});
});
// 题目不重复开关
els.questionNoRepeat.addEventListener('change', () => {
settings.questionNoRepeat = els.questionNoRepeat.checked;
Store.saveSettings(settings);
});
// 卡片墙筛选
document.querySelectorAll('.filter-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
wallFilter = btn.dataset.filter;
renderWall();
});
});
// 历史清空
els.clearHistoryBtn.addEventListener('click', () => {
Store.history = [];
Store.pickedIds.clear();
Store.save();
renderWall();
renderHistory();
toast('已清空记录', 'success');
});
// 刷新:运行时从服务器重拉学生名单与题库(教师在管理端改动后即时同步)
els.refreshBtn.addEventListener('click', () => {
if (rolling) { toast('抽取进行中,请稍后再试', 'error'); return; }
Store.load().then(() => {
renderWall();
renderHistory();
renderGroupSelect();
renderSubjectSelects();
updateModeToggleUI();
toast('已刷新学生名单与题库', 'success');
});
});
// 加载示例
els.loadDemoBtn.addEventListener('click', loadDemo);
// 设置
els.speedRange.addEventListener('input', () => {
settings.speed = parseInt(els.speedRange.value);
els.speedVal.textContent = speedLabel(settings.speed);
Store.saveSettings(settings);
});
els.soundToggle.addEventListener('change', () => {
settings.sound = els.soundToggle.checked;
Store.saveSettings(settings);
});
els.historyToggle.addEventListener('change', () => {
settings.history = els.historyToggle.checked;
Store.saveSettings(settings);
});
els.loadDemoBtn2.addEventListener('click', loadDemo);
els.clearAllBtn.addEventListener('click', clearAll);
// 键盘快捷键
document.addEventListener('keydown', (e) => {
if (e.key === ' ' && document.querySelector('.view-roll.active') && !isInputFocused()) {
e.preventDefault();
startRoll();
}
});
window.addEventListener('resize', () => { if (confettiParticles.length) resizeConfetti(); });
}
function isInputFocused() {
const el = document.activeElement;
return el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA');
}
/* ============ 初始化 ============ */
function init() {
// 数据改为后端 API 加载(失败回退本地缓存),加载完成后再渲染
Store.load().then(() => {
// 恢复设置到 UI
els.modeSwitch.querySelectorAll('.mode-btn').forEach(b => b.classList.toggle('active', b.dataset.mode === settings.mode));
els.multiConfig.hidden = !(settings.pickPerson && settings.mode === 'multi');
els.stepVal.textContent = settings.multiCount;
els.speedRange.value = settings.speed;
els.speedVal.textContent = speedLabel(settings.speed);
els.soundToggle.checked = settings.sound;
els.historyToggle.checked = settings.history;
els.questionNoRepeat.checked = settings.questionNoRepeat;
bindEvents();
renderWall();
renderHistory();
renderGroupSelect();
renderSubjectSelects();
updateModeToggleUI();
});
}
document.addEventListener('DOMContentLoaded', init);
})();