/* ============================================================ * 同学点名 - 课堂随机抽取系统 * 原生 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 ` ${patternSvg} ${escapeXml(ch)} `; } 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); })();