/* ============================================================
* 同学点名 - 课堂随机抽取系统
* 原生 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 = `
`;
break;
case 'stars':
patternSvg = `
`;
break;
case 'books':
patternSvg = `
`;
break;
case 'pencil':
patternSvg = `
`;
break;
case 'waves':
patternSvg = `
`;
break;
case 'grid':
patternSvg = `
`;
break;
}
return ``;
}
function escapeXml(s) {
return String(s).replace(/[<>&"']/g, c => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' }[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 = '' +
groups.map(g => ``).join('');
}
/* ============ 渲染:学科下拉 ============ */
function renderSubjectSelects() {
const subjects = Store.getSubjects();
const opts = '' +
subjects.map(s => ``).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 = `
${makeAvatar(s.name, s.avatar || 0)}
${escapeHtml(s.name)}
${escapeHtml(s.no || '')}
${s.group ? `${escapeHtml(s.group)}
` : ''}`;
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 `
${i + 1}
${makeAvatar(h.name, s.avatar || 0)}
${escapeHtml(h.name)}
${escapeHtml(h.no || '')}
${fmtTime(h.time)}
`;
}).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 = '抽取中…';
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 = `
${escapeHtml(question.subject)}
${escapeHtml(question.difficulty || '中等')}
${escapeHtml(question.content)}
`;
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 = `${escapeHtml((question.subject || '?').charAt(0))}
`;
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 = `
${idx}
${makeAvatar(student.name, student.avatar || 0)}
${escapeHtml(student.name)}
${escapeHtml(student.no || '')}
`;
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 => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' }[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 = '点击下方按钮开始抽题';
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);
})();